chore: apply prettier (#637)

* chore: apply prettier

* chore: edit ready comment

* chore: move ts comment
This commit is contained in:
SKINMAKER 2023-11-29 22:04:33 +09:00 committed by GitHub
parent a324106f9f
commit b421d1ab64
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
152 changed files with 8356 additions and 5120 deletions

View File

@ -1,25 +1,31 @@
import AdSense from 'react-adsense' import AdSense from 'react-adsense'
const Advertisement: React.FC<AdvertisementProps> = ({ size = 'short' }) => { const Advertisement: React.FC<AdvertisementProps> = ({ size = 'short' }) => {
return <div className='py-5'> return (
<div <div className='py-5'>
className={`z-0 mx-auto w-full text-center text-white ${ <div
process.env.NODE_ENV === 'production' ? '' : 'py-12 bg-gray-700' className={`z-0 mx-auto w-full text-center text-white ${
}`} process.env.NODE_ENV === 'production' ? '' : 'bg-gray-700 py-12'
style={size === 'short' ? { height: '90px' } : { height: '330px' }} }`}
> style={size === 'short' ? { height: '90px' } : { height: '330px' }}
{process.env.NODE_ENV === 'production' ? ( >
<AdSense.Google {process.env.NODE_ENV === 'production' ? (
style={{ display: 'inline-block', width: '100%', height: size === 'short' ? '90px' : '330px'}} <AdSense.Google
client='ca-pub-4856582423981759' style={{
slot='3250141451' display: 'inline-block',
format='' width: '100%',
/> height: size === 'short' ? '90px' : '330px',
) : ( }}
'Advertisement' client='ca-pub-4856582423981759'
)} slot='3250141451'
format=''
/>
) : (
'Advertisement'
)}
</div>
</div> </div>
</div> )
} }
declare global { declare global {

View File

@ -7,17 +7,16 @@ const ServerIcon = dynamic(() => import('@components/ServerIcon'))
const Application: React.FC<ApplicationProps> = ({ type, id, name }) => { const Application: React.FC<ApplicationProps> = ({ type, id, name }) => {
return ( return (
<Link href={`/developers/applications/${type + 's'}/${id}`} legacyBehavior> <Link href={`/developers/applications/${type + 's'}/${id}`} legacyBehavior>
<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'> <div className='relative transform cursor-pointer rounded-lg bg-little-white px-2 py-4 text-center transition duration-100 ease-in hover:-translate-y-1 dark:bg-discord-black'>
{ {type === 'bot' ? (
type === 'bot' ? <DiscordAvatar userID={id} className='w-full rounded-xl px-2' />
<DiscordAvatar userID={id} className='px-2 w-full rounded-xl' /> : ) : (
<ServerIcon id={id} className='px-2 w-full rounded-xl' /> <ServerIcon id={id} className='w-full rounded-xl px-2' />
} )}
<h2 className='pt-2 whitespace-nowrap text-xl font-medium truncate'>{name}</h2> <h2 className='truncate whitespace-nowrap pt-2 text-xl font-medium'>{name}</h2>
</div> </div>
</Link> </Link>
) )
} }
interface ApplicationProps { interface ApplicationProps {

View File

@ -11,18 +11,18 @@ const DiscordAvatar = dynamic(() => import('@components/DiscordAvatar'))
const BotCard: React.FC<BotCardProps> = ({ manage = false, bot }) => { const BotCard: React.FC<BotCardProps> = ({ manage = false, bot }) => {
return ( return (
<div className='min-w-0 container mb-16 transform hover:-translate-y-1 transition duration-100 ease-in cursor-pointer'> <div className='container mb-16 min-w-0 transform cursor-pointer transition duration-100 ease-in hover:-translate-y-1'>
<div className='relative'> <div className='relative'>
<div className='container mx-auto'> <div className='container mx-auto'>
<div className='h-full'> <div className='h-full'>
<div <div
className='relative mx-auto h-full text-black dark:text-white dark:bg-discord-black bg-little-white rounded-2xl shadow-xl' className='relative mx-auto h-full rounded-2xl bg-little-white text-black shadow-xl dark:bg-discord-black dark:text-white'
style={ style={
checkBotFlag(bot.flags, 'trusted') && bot.banner checkBotFlag(bot.flags, 'trusted') && bot.banner
? { ? {
background: `linear-gradient(to right, rgba(34, 36, 38, 0.68), rgba(34, 36, 38, 0.68)), url("${bot.banner}") center top / cover no-repeat`, background: `linear-gradient(to right, rgba(34, 36, 38, 0.68), rgba(34, 36, 38, 0.68)), url("${bot.banner}") center top / cover no-repeat`,
color: 'white', color: 'white',
} }
: {} : {}
} }
> >
@ -30,15 +30,15 @@ const BotCard: React.FC<BotCardProps> = ({ manage = false, bot }) => {
<div> <div>
<div className='flex flex-col'> <div className='flex flex-col'>
<div className='flex'> <div className='flex'>
<div className='w-3/5 flex justify-start'> <div className='flex w-3/5 justify-start'>
<DiscordAvatar <DiscordAvatar
size={128} size={128}
userID={bot.id} userID={bot.id}
alt='Avatar' alt='Avatar'
className='absolute -left-2 -top-8 mx-auto w-32 h-32 bg-white rounded-full' className='absolute -left-2 -top-8 mx-auto h-32 w-32 rounded-full bg-white'
/> />
</div> </div>
<div className='grid grid-cols-1 pr-5 pt-5 w-2/5'> <div className='grid w-2/5 grid-cols-1 pr-5 pt-5'>
<Tag <Tag
text={ text={
<> <>
@ -55,19 +55,19 @@ const BotCard: React.FC<BotCardProps> = ({ manage = false, bot }) => {
</div> </div>
</div> </div>
</div> </div>
<div className='mt-3 px-4 h-16'> <div className='mt-3 h-16 px-4'>
<h2 className='px-1 text-sm'> <h2 className='px-1 text-sm'>
<i className={`fas fa-circle text-${Status[bot.status]?.color}`} /> <i className={`fas fa-circle text-${Status[bot.status]?.color}`} />
{Status[bot.status]?.text} {Status[bot.status]?.text}
</h2> </h2>
<h1 className='mb-3 text-left text-xl sm:text-2xl font-bold truncate'>{bot.name}</h1> <h1 className='mb-3 truncate text-left text-xl font-bold sm:text-2xl'>
{bot.name}
</h1>
</div> </div>
<p className='mb-10 px-4 h-6 text-left text-gray-400 text-sm'> <p className='mb-10 h-6 px-4 text-left text-sm text-gray-400'>{bot.intro}</p>
{bot.intro}
</p>
<div> <div>
<div className='category flex flex-wrap px-2'> <div className='category flex flex-wrap px-2'>
{bot.category.slice(0, 3).map(el => ( {bot.category.slice(0, 3).map((el) => (
<Tag key={el} text={el} href={`/bots/categories/${el}`} dark /> <Tag key={el} text={el} href={`/bots/categories/${el}`} dark />
))}{' '} ))}{' '}
{bot.category.length > 3 && <Tag text={`+${bot.category.length - 3}`} dark />} {bot.category.length > 3 && <Tag text={`+${bot.category.length - 3}`} dark />}
@ -80,35 +80,31 @@ const BotCard: React.FC<BotCardProps> = ({ manage = false, bot }) => {
<div className='flex justify-evenly'> <div className='flex justify-evenly'>
<Link <Link
href={makeBotURL(bot)} href={makeBotURL(bot)}
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'> className='w-full rounded-bl-2xl py-3 text-center text-sm font-bold text-koreanbots-blue transition duration-100 ease-in hover:bg-koreanbots-blue hover:text-white hover:shadow-lg'
>
</Link> </Link>
{manage ? ( {manage ? (
<Link <Link
href={`/bots/${bot.id}/edit`} href={`/bots/${bot.id}/edit`}
className='py-3 w-full text-center text-emerald-500 hover:text-white text-sm font-bold hover:bg-emerald-500 rounded-br-2xl hover:shadow-lg transition duration-100 ease-in'> className='w-full rounded-br-2xl py-3 text-center text-sm font-bold text-emerald-500 transition duration-100 ease-in hover:bg-emerald-500 hover:text-white hover:shadow-lg'
>
</Link> </Link>
) : bot.state !== 'ok' ? <a ) : bot.state !== 'ok' ? (
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 className='w-full cursor-default select-none rounded-br-2xl py-3 text-center text-sm font-bold text-discord-blurple opacity-50 transition duration-100 ease-in hover:shadow-lg'>
>
</a>
</a> : ) : (
<a <a
href={ href={makeBotURL(bot) + '/invite'}
makeBotURL(bot) + '/invite'
}
rel='noopener noreferrer' rel='noopener noreferrer'
target='_blank' 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' className='w-full rounded-br-2xl py-3 text-center text-sm font-bold text-discord-blurple transition duration-100 ease-in hover:bg-discord-blurple hover:text-white hover:shadow-lg'
> >
</a> </a>
} )}
</div> </div>
</div> </div>
</div> </div>
@ -117,7 +113,6 @@ const BotCard: React.FC<BotCardProps> = ({ manage = false, bot }) => {
</div> </div>
</div> </div>
) )
} }
interface BotCardProps { interface BotCardProps {
@ -125,4 +120,4 @@ interface BotCardProps {
bot: Bot bot: Bot
} }
export default BotCard export default BotCard

View File

@ -6,33 +6,41 @@ const Button: React.FC<ButtonProps> = ({
className, className,
children, children,
href, href,
disabled=false, disabled = false,
onClick, onClick,
}) => { }) => {
return href ? <Link return href ? (
href={!disabled && href} <Link
className={`cursor-pointer rounded-md px-4 py-2 transition duration-300 ease select-none outline-none foucs:outline-none mr-1.5 ${className ?? href={!disabled && href}
'bg-discord-blurple hover:opacity-80 dark:bg-very-black dark:hover:bg-discord-dark-hover text-white'}`}> className={`ease foucs:outline-none mr-1.5 cursor-pointer select-none rounded-md px-4 py-2 outline-none transition duration-300 ${
className ??
{children} 'bg-discord-blurple text-white hover:opacity-80 dark:bg-very-black dark:hover:bg-discord-dark-hover'
}`}
</Link> >
: onClick ? <button {children}
</Link>
) : onClick ? (
<button
type={disabled ? 'button' : type} type={disabled ? 'button' : type}
onClick={disabled ? null : onClick} onClick={disabled ? null : onClick}
className={`cursor-pointer rounded-md px-4 py-2 transition duration-300 ease select-none outline-none foucs:outline-none mr-1.5 ${className ?? className={`ease foucs:outline-none mr-1.5 cursor-pointer select-none rounded-md px-4 py-2 outline-none transition duration-300 ${
'bg-discord-blurple hover:opacity-80 dark:bg-very-black dark:hover:bg-discord-dark-hover text-white'}`} className ??
'bg-discord-blurple text-white hover:opacity-80 dark:bg-very-black dark:hover:bg-discord-dark-hover'
}`}
> >
{children} {children}
</button> </button>
: ) : (
<button <button
type={disabled ? 'button' : type} type={disabled ? 'button' : type}
className={`cursor-pointer rounded-md px-4 py-2 transition duration-300 ease select-none outline-none foucs:outline-none mr-1.5 ${className ?? className={`ease foucs:outline-none mr-1.5 cursor-pointer select-none rounded-md px-4 py-2 outline-none transition duration-300 ${
'bg-discord-blurple hover:opacity-80 dark:bg-very-black dark:hover:bg-discord-dark-hover text-white'}`} className ??
> 'bg-discord-blurple text-white hover:opacity-80 dark:bg-very-black dark:hover:bg-discord-dark-hover'
{children} }`}
</button> >
{children}
</button>
)
} }
interface ButtonProps { interface ButtonProps {

View File

@ -1,15 +1,20 @@
import { Ref } from 'react' import { Ref } from 'react'
import HCaptcha from '@hcaptcha/react-hcaptcha' import HCaptcha from '@hcaptcha/react-hcaptcha'
const Captcha: React.FC<CaptchaProps> = ({ dark, onVerify }) => { const Captcha: React.FC<CaptchaProps> = ({ dark, onVerify }) => {
return <HCaptcha sitekey={process.env.NEXT_PUBLIC_HCAPTCHA_SITEKEY} theme={dark ? 'dark' : 'light'} onVerify={onVerify}/> return (
<HCaptcha
sitekey={process.env.NEXT_PUBLIC_HCAPTCHA_SITEKEY}
theme={dark ? 'dark' : 'light'}
onVerify={onVerify}
/>
)
} }
interface CaptchaProps { interface CaptchaProps {
dark: boolean dark: boolean
onVerify(token: string, eKey?: string): void onVerify(token: string, eKey?: string): void
ref?: Ref<HCaptcha> ref?: Ref<HCaptcha>
} }
export default Captcha export default Captcha

View File

@ -3,7 +3,7 @@ const ColorCard: React.FC<ColorCardProps> = ({ header, first, second, className
<div className={`rounded-lg p-10 ${className} shadow-lg`}> <div className={`rounded-lg p-10 ${className} shadow-lg`}>
<h2 className='text-2xl font-bold'>{header}</h2> <h2 className='text-2xl font-bold'>{header}</h2>
<p className='opacity-80'> <p className='opacity-80'>
{first} {first}
<br /> <br />
{second} {second}
</p> </p>

View File

@ -9,106 +9,169 @@ import { NextSeo } from 'next-seo'
const Container = dynamic(() => import('@components/Container')) const Container = dynamic(() => import('@components/Container'))
const Divider = dynamic(() => import('@components/Divider')) const Divider = dynamic(() => import('@components/Divider'))
const DeveloperLayout: React.FC<DeveloperLayout> = ({ children, enabled, docs, currentDoc }:DeveloperLayout) => { const DeveloperLayout: React.FC<DeveloperLayout> = ({
const [ navbarEnabled, setNavbarOpen ] = useState(false) children,
enabled,
docs,
currentDoc,
}: DeveloperLayout) => {
const [navbarEnabled, setNavbarOpen] = useState(false)
return ( return (
<div className='flex min-h-screen'> <div className='min-h-screen flex'>
<NextSeo title='한디리 개발자' description='한국 디스코드 리스트 API를 활용하여 봇에 다양한 기능을 추가해보세요.' openGraph={{ <NextSeo
title:'한디리 개발자', title='한디리 개발자'
description:'한국 디스코드 리스트 API를 활용하여 봇에 다양한 기능을 추가해보세요.' description='한국 디스코드 리스트 API를 활용하여 봇에 다양한 기능을 추가해보세요.'
}} /> openGraph={{
<div className='block lg:hidden h-screen relative'> title: '한디리 개발자',
<div className='w-18 pt-20 px-2 h-full text-center bg-little-white dark:bg-discord-black fixed'> description: '한국 디스코드 리스트 API를 활용하여 봇에 다양한 기능을 추가해보세요.',
}}
/>
<div className='relative block h-screen lg:hidden'>
<div className='w-18 fixed h-full bg-little-white px-2 pt-20 text-center dark:bg-discord-black'>
<ul className='text-gray-600 dark:text-gray-300'> <ul className='text-gray-600 dark:text-gray-300'>
<li className={`cursor-pointer py-2 px-4 mb-2 rounded-md ${enabled === 'applications' ? 'bg-discord-blurple text-white' : 'hover:text-gray-500 dark:hover:text-white'}`}> <li
<Link href='/developers/applications' legacyBehavior><i className='fas fa-robot'/></Link> className={`mb-2 cursor-pointer rounded-md px-4 py-2 ${
enabled === 'applications'
? 'bg-discord-blurple text-white'
: 'hover:text-gray-500 dark:hover:text-white'
}`}
>
<Link href='/developers/applications' legacyBehavior>
<i className='fas fa-robot' />
</Link>
</li> </li>
<li className={`cursor-pointer py-2 px-4 my-2 rounded-md ${enabled === 'docs' ? 'bg-discord-blurple text-white' : 'hover:text-gray-500 dark:hover:text-white'}`}> <li
<Link href='/developers/docs' legacyBehavior><i className='fas fa-book'/></Link> className={`my-2 cursor-pointer rounded-md px-4 py-2 ${
enabled === 'docs'
? 'bg-discord-blurple text-white'
: 'hover:text-gray-500 dark:hover:text-white'
}`}
>
<Link href='/developers/docs' legacyBehavior>
<i className='fas fa-book' />
</Link>
</li> </li>
{ {enabled === 'docs' && (
enabled === 'docs' && <> <>
<Divider /> <Divider />
<li className='cursor-pointer py-2 px-4 my-2 rounded-md hover:text-gray-500 dark:hover:text-white' onKeyDown={() => setNavbarOpen(true)} onClick={() => setNavbarOpen(true)}> <li
<i className='fas fa-bars'/> className='my-2 cursor-pointer rounded-md px-4 py-2 hover:text-gray-500 dark:hover:text-white'
</li></> onKeyDown={() => setNavbarOpen(true)}
} onClick={() => setNavbarOpen(true)}
>
<i className='fas fa-bars' />
</li>
</>
)}
</ul> </ul>
</div> </div>
</div> </div>
<div className={`${navbarEnabled ? 'block' : 'hidden'} lg:block relative`}> <div className={`${navbarEnabled ? 'block' : 'hidden'} relative lg:block`}>
<div className='bg-little-white dark:bg-discord-black pt-20 px-6 fixed h-screen w-screen lg:w-60 overflow-y-auto'> <div className='fixed h-screen w-screen overflow-y-auto bg-little-white px-6 pt-20 dark:bg-discord-black lg:w-60'>
<ul className='text-base text-gray-600 dark:text-gray-300 mb-6 hidden lg:block'> <ul className='mb-6 hidden text-base text-gray-600 dark:text-gray-300 lg:block'>
<li className='cursor-pointer py-2 px-4 rounded-md hover:text-gray-500 dark:hover:text-white lg:hidden' onKeyDown={() => setNavbarOpen(false)} onClick={() => setNavbarOpen(false)}></li> <li
className='cursor-pointer rounded-md px-4 py-2 hover:text-gray-500 dark:hover:text-white lg:hidden'
onKeyDown={() => setNavbarOpen(false)}
onClick={() => setNavbarOpen(false)}
>
</li>
<Divider className='lg:hidden' /> <Divider className='lg:hidden' />
<Link href='/developers/applications' legacyBehavior> <Link href='/developers/applications' legacyBehavior>
<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
className={`cursor-pointer rounded-md px-4 py-2 ${
enabled === 'applications'
? 'bg-discord-blurple text-white'
: 'hover:text-gray-500 dark:hover:text-white'
}`}
>
</li> </li>
</Link> </Link>
<Link href='/developers/docs' legacyBehavior> <Link href='/developers/docs' legacyBehavior>
<li className={`cursor-pointer py-2 px-4 rounded-md ${enabled === 'docs' ? 'bg-discord-blurple text-white' : 'hover:text-gray-500 dark:hover:text-white'}`}> <li
className={`cursor-pointer rounded-md px-4 py-2 ${
enabled === 'docs'
? 'bg-discord-blurple text-white'
: 'hover:text-gray-500 dark:hover:text-white'
}`}
>
</li> </li>
</Link> </Link>
</ul> </ul>
{ {enabled === 'docs' && (
enabled === 'docs' && <> <>
<Divider className='hidden lg:block' /> <Divider className='hidden lg:block' />
<ul className='text-sm text-gray-600 dark:text-gray-300 px-0.5 lg:mt-6'> <ul className='px-0.5 text-sm text-gray-600 dark:text-gray-300 lg:mt-6'>
<li onClick={() => setNavbarOpen(false)} className='lg:hidden cursor-pointer py-1 px-4 rounded-md mb-2'> <li
onClick={() => setNavbarOpen(false)}
className='mb-2 cursor-pointer rounded-md px-4 py-1 lg:hidden'
>
<i className='fas fa-times' /> <i className='fas fa-times' />
</li> </li>
<Divider className='lg:hidden' /> <Divider className='lg:hidden' />
{ {docs?.map((el) => {
docs?.map(el => { if (el.list)
if(el.list) return ( return (
<div key={el.name} className='mt-2'> <div key={el.name} className='mt-2'>
<span className='text-gray-600 dark:text-gray-100 font-bold mb-1'>{el.name}</span> <span className='mb-1 font-bold text-gray-600 dark:text-gray-100'>
<ul className='text-sm py-3'> {el.name}
{ </span>
el.list.map(e => <ul className='py-3 text-sm'>
<Link {el.list.map((e) => (
key={e.name} <Link
href={`/developers/docs/${el.name}/${e.name}`} key={e.name}
legacyBehavior> href={`/developers/docs/${el.name}/${e.name}`}
<li onClick={() => setNavbarOpen(false)} className={`cursor-pointer px-4 py-2 rounded-md ${currentDoc === e.name ? 'bg-discord-blurple text-white' : 'hover:text-gray-500 dark:hover:text-white'}`}> legacyBehavior
{e.name} >
</li> <li
</Link> onClick={() => setNavbarOpen(false)}
) className={`cursor-pointer rounded-md px-4 py-2 ${
} currentDoc === e.name
? 'bg-discord-blurple text-white'
: 'hover:text-gray-500 dark:hover:text-white'
}`}
>
{e.name}
</li>
</Link>
))}
</ul> </ul>
</div> </div>
) )
return ( return (
<Link key={el.name} href={`/developers/docs/${el.name}`} legacyBehavior> <Link key={el.name} href={`/developers/docs/${el.name}`} legacyBehavior>
<li onClick={() => setNavbarOpen(false)} className={`cursor-pointer py-2 px-4 rounded-md ${currentDoc === el.name ? 'bg-discord-blurple text-white' : 'hover:text-gray-500 dark:hover:text-white'}`}> <li
{el.name} onClick={() => setNavbarOpen(false)}
</li> className={`cursor-pointer rounded-md px-4 py-2 ${
</Link> currentDoc === el.name
) ? 'bg-discord-blurple text-white'
}) : 'hover:text-gray-500 dark:hover:text-white'
} }`}
>
{el.name}
</li>
</Link>
)
})}
</ul> </ul>
</> </>
} )}
</div> </div>
</div> </div>
<div className='w-full py-28 lg:pl-60 pl-16'> <div className='w-full py-28 pl-16 lg:pl-60'>
<Container> <Container>{children}</Container>
{children}
</Container>
</div> </div>
</div> </div>
) )
} }
interface DeveloperLayout { interface DeveloperLayout {
children: ReactNode children: ReactNode
enabled: 'applications' | 'docs' enabled: 'applications' | 'docs'
docs?: DocsData[] docs?: DocsData[]
currentDoc?: string currentDoc?: string
} }

View File

@ -4,20 +4,27 @@ import { KoreanbotsEndPoints } from '@utils/Constants'
const Image = dynamic(() => import('@components/Image')) const Image = dynamic(() => import('@components/Image'))
const DiscordAvatar: React.FC<DiscordAvatarProps> = props => { const DiscordAvatar: React.FC<DiscordAvatarProps> = (props) => {
return <Image return (
{...props} <Image
src={KoreanbotsEndPoints.CDN.avatar(props.userID, { format: 'webp', size: props.size ?? 256})} {...props}
fallbackSrc={KoreanbotsEndPoints.CDN.avatar(props.userID, { format: 'png', size: props.size ?? 256})} 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 { interface DiscordAvatarProps {
alt?: string alt?: string
userID: string userID: string
className?: string className?: string
size? : 128 | 256 | 512 size?: 128 | 256 | 512
} }
interface ImageEvent extends Event { interface ImageEvent extends Event {

View File

@ -8,26 +8,28 @@ const Docs: React.FC<DocsProps> = ({ title, header, description, subheader, chil
const d = description || subheader const d = description || subheader
return ( return (
<> <>
<NextSeo title={t} description={d} <NextSeo
title={t}
description={d}
openGraph={{ openGraph={{
title: t, title: t,
description: d description: d,
}} }}
/> />
<div className='dark:bg-discord-black bg-discord-blurple z-20'> <div className='z-20 bg-discord-blurple dark:bg-discord-black'>
<Container className='py-20' ignoreColor> <Container className='py-20' ignoreColor>
<h1 className='mt-10 text-center text-gray-100 text-4xl font-bold sm:text-left'> <h1 className='mt-10 text-center text-4xl font-bold text-gray-100 sm:text-left'>
{header} {header}
</h1> </h1>
<h2 className='mt-5 text-center text-gray-200 text-xl font-medium sm:text-left'> <h2 className='mt-5 text-center text-xl font-medium text-gray-200 sm:text-left'>
{description} {description}
</h2> </h2>
<h2 className='mt-5 text-center text-gray-200 text-xl font-medium sm:text-left'> <h2 className='mt-5 text-center text-xl font-medium text-gray-200 sm:text-left'>
{subheader} {subheader}
</h2> </h2>
</Container> </Container>
</div> </div>
<Container className='pt-10 pb-20'> <Container className='pb-20 pt-10'>
<div>{children}</div> <div>{children}</div>
</Container> </Container>
</> </>

View File

@ -9,16 +9,16 @@ const Toggle = dynamic(() => import('@components/Toggle'))
const Footer: React.FC<FooterProps> = ({ theme, setTheme }) => { const Footer: React.FC<FooterProps> = ({ theme, setTheme }) => {
return ( return (
<div className='releative z-30'> <div className='releative z-30'>
<div className='bottom-0 text-white bg-discord-black py-24'> <div className='bottom-0 bg-discord-black py-24 text-white'>
<Container className='w-11/12 lg:flex lg:pt-0 lg:w-4/5' ignoreColor> <Container className='w-11/12 lg:flex lg:w-4/5 lg:pt-0' ignoreColor>
<div className='w-full lg:w-2/5'> <div className='w-full lg:w-2/5'>
<h1 className='text-koreanbots-blue text-2xl font-bold'> .</h1> <h1 className='text-2xl font-bold text-koreanbots-blue'>
.
</h1>
<span className='text-base'>2020-2023 , All rights reserved.</span> <span className='text-base'>2020-2023 , All rights reserved.</span>
<div className='text-2xl flex space-x-1'> <div className='flex space-x-1 text-2xl'>
<Link href='/discord'> <Link href='/discord'>
<i className='fab fa-discord inline-block w-full' /> <i className='fab fa-discord inline-block w-full' />
</Link> </Link>
<a href='https://github.com/koreanbots'> <a href='https://github.com/koreanbots'>
<i className='fab fa-github inline-block w-full' /> <i className='fab fa-github inline-block w-full' />
@ -28,9 +28,9 @@ const Footer: React.FC<FooterProps> = ({ theme, setTheme }) => {
</a> </a>
</div> </div>
</div> </div>
<div className='grid grow gap-2 grid-cols-2 md:grid-cols-7'> <div className='grid grow grid-cols-2 gap-2 md:grid-cols-7'>
<div className='col-span-2 mb-2'> <div className='col-span-2 mb-2'>
<h2 className='text-koreanbots-blue text-base font-bold'> </h2> <h2 className='text-base font-bold text-koreanbots-blue'> </h2>
<ul className='text-sm'> <ul className='text-sm'>
<li> <li>
<Link href='/about' className='hover:text-gray-300'> <Link href='/about' className='hover:text-gray-300'>
@ -50,7 +50,7 @@ const Footer: React.FC<FooterProps> = ({ theme, setTheme }) => {
</ul> </ul>
</div> </div>
<div className='col-span-2 mb-2'> <div className='col-span-2 mb-2'>
<h2 className='text-koreanbots-blue text-base font-bold'></h2> <h2 className='text-base font-bold text-koreanbots-blue'></h2>
<ul className='text-sm'> <ul className='text-sm'>
<li> <li>
<Link href='/tos' className='hover:text-gray-300'> <Link href='/tos' className='hover:text-gray-300'>
@ -75,7 +75,7 @@ const Footer: React.FC<FooterProps> = ({ theme, setTheme }) => {
</ul> </ul>
</div> </div>
<div className='col-span-1 mb-2'> <div className='col-span-1 mb-2'>
<h2 className='text-koreanbots-blue text-base font-bold'></h2> <h2 className='text-base font-bold text-koreanbots-blue'></h2>
<ul className='text-sm'> <ul className='text-sm'>
{/* <li> {/* <li>
<Link href='/partners'> <Link href='/partners'>
@ -90,7 +90,7 @@ const Footer: React.FC<FooterProps> = ({ theme, setTheme }) => {
</ul> </ul>
</div> </div>
<div className='col-span-2 mb-2'> <div className='col-span-2 mb-2'>
<h2 className='text-koreanbots-blue text-base font-bold'></h2> <h2 className='text-base font-bold text-koreanbots-blue'></h2>
<div className='flex'> <div className='flex'>
<a className='mr-2 hover:text-gray-300'></a> <a className='mr-2 hover:text-gray-300'></a>
<Toggle <Toggle

View File

@ -6,21 +6,23 @@ import { ErrorText } from '@utils/Constants'
const Button = dynamic(() => import('@components/Button')) const Button = dynamic(() => import('@components/Button'))
const Forbidden:React.FC = () => { const Forbidden: React.FC = () => {
const router = useRouter() const router = useRouter()
return <> return (
<NextSeo title='권한이 없습니다' /> <>
<div className='flex items-center justify-center h-screen select-none'> <NextSeo title='권한이 없습니다' />
<div className='container mx-auto px-20 md:text-left text-center'> <div className='flex h-screen select-none items-center justify-center'>
<h1 className='text-8xl font-semibold'>403</h1> <div className='container mx-auto px-20 text-center md:text-left'>
<h2 className='text-2xl font-semibold py-2'> <h1 className='text-8xl font-semibold'>403</h1>
{ErrorText[403]} <h2 className='py-2 text-2xl font-semibold'>{ErrorText[403]}</h2>
</h2> <Button onClick={router.back}> </Button>
<Button onClick={router.back}> </Button> <p className='mt-2 text-sm text-gray-400'>
<p className='text-gray-400 text-sm mt-2'> . .</p> . .
</p>
</div>
</div> </div>
</div> </>
</> )
} }
export default Forbidden export default Forbidden

View File

@ -1,7 +1,14 @@
import { Field } from 'formik' import { Field } from 'formik'
const CheckBox: React.FC<CheckBoxProps> = ({ name, ...props }) => { const CheckBox: React.FC<CheckBoxProps> = ({ name, ...props }) => {
return <Field type='checkbox' name={name} className='form-checkbox text-koreanbots-blue bg-gray-300 h-4 w-4 rounded' {...props} /> return (
<Field
type='checkbox'
name={name}
className='form-checkbox h-4 w-4 rounded bg-gray-300 text-koreanbots-blue'
{...props}
/>
)
} }
interface CheckBoxProps { interface CheckBoxProps {

View File

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

View File

@ -6,28 +6,30 @@ const Label: React.FC<LabelProps> = ({
error = null, error = null,
grid = true, grid = true,
short = false, short = false,
required = false required = false,
}) => { }) => {
return <label return (
className={grid ? 'grid grid-cols-1 xl:grid-cols-4 gap-2 my-4' : 'inline-flex items-center'} <label
htmlFor={For} className={grid ? 'my-4 grid grid-cols-1 gap-2 xl:grid-cols-4' : 'inline-flex items-center'}
> htmlFor={For}
{label && ( >
<div className='col-span-1 text-sm'> {label && (
<h3 className='text-koreanbots-blue text-lg font-bold'> <div className='col-span-1 text-sm'>
{label} <h3 className='text-lg font-bold text-koreanbots-blue'>
{required && ( {label}
<span className='align-text-top text-red-500 text-base font-semibold'> *</span> {required && (
)} <span className='align-text-top text-base font-semibold text-red-500'> *</span>
</h3> )}
{labelDesc} </h3>
{labelDesc}
</div>
)}
<div className={short ? 'col-span-1' : 'col-span-3'}>
{children}
<div className='mt-1 text-xs font-light text-red-500'>{error}</div>
</div> </div>
)} </label>
<div className={short ? 'col-span-1' : 'col-span-3'}> )
{children}
<div className='mt-1 text-red-500 text-xs font-light'>{error}</div>
</div>
</label>
} }
interface LabelProps { interface LabelProps {

View File

@ -1,42 +1,50 @@
import ReactSelect from 'react-select' import ReactSelect from 'react-select'
const Select: React.FC<SelectProps> = ({ placeholder, options, handleChange, handleTouch, value }) => { const Select: React.FC<SelectProps> = ({
return <ReactSelect placeholder,
styles={{ options,
control: provided => { handleChange,
return { ...provided, border: 'none' } handleTouch,
}, value,
option: provided => { }) => {
return { return (
...provided, <ReactSelect
cursor: 'pointer', styles={{
':hover': { control: (provided) => {
opacity: '0.7', return { ...provided, border: 'none' }
}, },
} option: (provided) => {
}, return {
placeholder: provided => { ...provided,
return { cursor: 'pointer',
...provided, ':hover': {
position: 'absolute' opacity: '0.7',
} },
}, }
singleValue: provided => { },
return { placeholder: (provided) => {
...provided, return {
position: 'absolute' ...provided,
} position: 'absolute',
} }
}} },
className='border-grey-light border dark:border-transparent rounded' singleValue: (provided) => {
classNamePrefix='outline-none text-black dark:bg-very-black dark:text-white ' return {
placeholder={placeholder || '선택해주세요.'} ...provided,
options={options} position: 'absolute',
onChange={handleChange} }
onBlur={handleTouch} },
noOptionsMessage={() => '검색 결과가 없습니다.'} }}
defaultValue={value} className='border-grey-light rounded border dark:border-transparent'
/> classNamePrefix='outline-none text-black dark:bg-very-black dark:text-white '
placeholder={placeholder || '선택해주세요.'}
options={options}
onChange={handleChange}
onBlur={handleTouch}
noOptionsMessage={() => '검색 결과가 없습니다.'}
defaultValue={value}
/>
)
} }
interface SelectProps { interface SelectProps {

View File

@ -1,9 +1,5 @@
import React, { MouseEventHandler } from 'react' import React, { MouseEventHandler } from 'react'
import ReactSelect, { import ReactSelect, { components, MultiValueProps, MultiValueRemoveProps } from 'react-select'
components,
MultiValueProps,
MultiValueRemoveProps,
} from 'react-select'
import { closestCenter, DndContext, DragEndEvent } from '@dnd-kit/core' import { closestCenter, DndContext, DragEndEvent } from '@dnd-kit/core'
import { restrictToParentElement } from '@dnd-kit/modifiers' import { restrictToParentElement } from '@dnd-kit/modifiers'
import { import {
@ -20,8 +16,9 @@ const MultiValue = (props: MultiValueProps<Option>) => {
e.stopPropagation() e.stopPropagation()
} }
const innerProps = { ...props.innerProps, onMouseDown } const innerProps = { ...props.innerProps, onMouseDown }
const { attributes, listeners, setNodeRef, transform, transition } = const { attributes, listeners, setNodeRef, transform, transition } = useSortable({
useSortable({ id: props.data.value }) id: props.data.value,
})
const style = { const style = {
transform: CSS.Transform.toString(transform), transform: CSS.Transform.toString(transform),
transition, transition,
@ -46,61 +43,80 @@ const MultiValueRemove = (props: MultiValueRemoveProps<Option>) => {
) )
} }
const Select: React.FC<SelectProps> = ({ placeholder, options, values, setValues, handleChange, handleTouch }) => { const Select: React.FC<SelectProps> = ({
placeholder,
options,
values,
setValues,
handleChange,
handleTouch,
}) => {
const onSortEnd = (event: DragEndEvent) => { const onSortEnd = (event: DragEndEvent) => {
const { active, over } = event const { active, over } = event
const newValue = arrayMove(values, values.findIndex(i => i === active.id), values.findIndex(i => i === over.id)) const newValue = arrayMove(
values,
values.findIndex((i) => i === active.id),
values.findIndex((i) => i === over.id)
)
setValues(newValue) setValues(newValue)
} }
return <DndContext modifiers={[restrictToParentElement]} onDragEnd={onSortEnd} collisionDetection={closestCenter}> return (
<SortableContext <DndContext
items={values} modifiers={[restrictToParentElement]}
strategy={horizontalListSortingStrategy}> onDragEnd={onSortEnd}
<ReactSelect collisionDetection={closestCenter}
styles={{ >
placeholder: (provided) => { <SortableContext items={values} strategy={horizontalListSortingStrategy}>
return { ...provided, position: 'absolute' } <ReactSelect
}, styles={{
control: (provided) => { placeholder: (provided) => {
return { ...provided, border: 'none' } return { ...provided, position: 'absolute' }
}, },
option: (provided) => { control: (provided) => {
return { ...provided, cursor: 'pointer', ':hover': { return { ...provided, border: 'none' }
opacity: '0.7' },
} } option: (provided) => {
} return {
}} ...provided,
isMulti cursor: 'pointer',
className='border border-grey-light dark:border-transparent rounded' ':hover': {
classNamePrefix='outline-none text-black dark:bg-very-black dark:text-white cursor-pointer ' opacity: '0.7',
placeholder={placeholder || '선택해주세요.'} },
options={options} }
onChange={handleChange} },
onBlur={handleTouch} }}
noOptionsMessage={() => '검색 결과가 없습니다.'} isMulti
value={values.map(el => ({ label: el, value: el}))} className='border-grey-light rounded border dark:border-transparent'
components={{ classNamePrefix='outline-none text-black dark:bg-very-black dark:text-white cursor-pointer '
MultiValue, placeholder={placeholder || '선택해주세요.'}
MultiValueRemove, options={options}
}} onChange={handleChange}
closeMenuOnSelect={false} onBlur={handleTouch}
/> noOptionsMessage={() => '검색 결과가 없습니다.'}
</SortableContext> value={values.map((el) => ({ label: el, value: el }))}
</DndContext> components={{
MultiValue,
MultiValueRemove,
}}
closeMenuOnSelect={false}
/>
</SortableContext>
</DndContext>
)
} }
interface SelectProps { interface SelectProps {
placeholder?: string placeholder?: string
options: Option[] options: Option[]
values: string[] values: string[]
setValues: (value: string[]) => void setValues: (value: string[]) => void
handleChange: (value: Option[]) => void handleChange: (value: Option[]) => void
handleTouch: () => void handleTouch: () => void
} }
interface Option { interface Option {
value: string value: string
label: string label: string
} }
export default Select export default Select

View File

@ -8,64 +8,93 @@ import useOutsideClick from '@utils/useOutsideClick'
import 'emoji-mart/css/emoji-mart.css' import 'emoji-mart/css/emoji-mart.css'
const TextArea: React.FC<TextAreaProps> = ({
name,
const TextArea: React.FC<TextAreaProps> = ({ name, placeholder, theme='auto', max, setValue, value }) => { placeholder,
theme = 'auto',
max,
setValue,
value,
}) => {
const ref = useRef() const ref = useRef()
const [ emojiPickerHidden, setEmojiPickerHidden ] = useState(true) const [emojiPickerHidden, setEmojiPickerHidden] = useState(true)
useOutsideClick(ref, () => { useOutsideClick(ref, () => {
setEmojiPickerHidden(true) setEmojiPickerHidden(true)
}) })
return <div className='border border-grey-light dark:border-transparent h-96 text-black dark:bg-very-black dark:text-white rounded px-4 py-3 inline-block relative w-full'> return (
<Field as='textarea' name={name} className='dark:border-transparent text-black dark:bg-very-black dark:text-white w-full relative h-full resize-none outline-none' placeholder={placeholder} /> <div className='border-grey-light relative inline-block h-96 w-full rounded border px-4 py-3 text-black dark:border-transparent dark:bg-very-black dark:text-white'>
<div ref={ref}> <Field
<div className='absolute bottom-12 left-10 z-30'> as='textarea'
{ name={name}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment className='relative h-full w-full resize-none text-black outline-none dark:border-transparent dark:bg-very-black dark:text-white'
// @ts-ignore placeholder={placeholder}
!emojiPickerHidden && <Picker title='선택해주세요' emoji='sunglasses' set='twitter' enableFrequentEmojiSort theme={theme} showSkinTones={false} onSelect={(e) => { />
setEmojiPickerHidden(true) <div ref={ref}>
setValue(value + ' ' + ((e as { native: string }).native || e.colons)) <div className='absolute bottom-12 left-10 z-30'>
}} i18n={{ {!emojiPickerHidden && (
search: '검색', // eslint-disable-next-line @typescript-eslint/ban-ts-comment
notfound: '검색 결과가 없습니다.', // @ts-ignore
categories: { <Picker
search: '검색 결과', title='선택해주세요'
recent: '최근 사용', emoji='sunglasses'
people: '사람', set='twitter'
nature: '자연', enableFrequentEmojiSort
foods: '음식', theme={theme}
activity: '활동', showSkinTones={false}
places: '장소', onSelect={(e) => {
objects: '사물', setEmojiPickerHidden(true)
symbols: '기호', setValue(value + ' ' + ((e as { native: string }).native || e.colons))
flags: '국기', }}
custom: '커스텀' i18n={{
} search: '검색',
}} custom={KoreanbotsEmoji}/> notfound: '검색 결과가 없습니다.',
} categories: {
search: '검색 결과',
recent: '최근 사용',
people: '사람',
nature: '자연',
foods: '음식',
activity: '활동',
places: '장소',
objects: '사물',
symbols: '기호',
flags: '국기',
custom: '커스텀',
},
}}
custom={KoreanbotsEmoji}
/>
)}
</div>
<div className='absolute bottom-2 left-4 hidden sm:block'>
<div
className='emoji-selector-button outline-none'
onClick={() => setEmojiPickerHidden(false)}
onKeyPress={() => setEmojiPickerHidden(false)}
role='button'
tabIndex={0}
/>
</div>
{max && (
<span
className={`absolute bottom-2 right-4 ${max < value.length ? ' text-red-400' : ''}`}
>
{max - value.length}
</span>
)}
</div> </div>
<div className='absolute bottom-2 left-4 hidden sm:block'>
<div className='emoji-selector-button outline-none' onClick={() => setEmojiPickerHidden(false)} onKeyPress={() => setEmojiPickerHidden(false)} role='button' tabIndex={0} />
</div>
{
max && <span className={`absolute bottom-2 right-4 ${max < value.length ? ' text-red-400' : ''}`}>
{max-value.length}
</span>
}
</div> </div>
</div> )
} }
interface TextAreaProps { interface TextAreaProps {
name: string name: string
placeholder?: string placeholder?: string
theme?: 'auto' | 'dark' | 'light' theme?: 'auto' | 'dark' | 'light'
max?: number max?: number
value: string value: string
setValue(value: string): void setValue(value: string): void
} }
export default TextArea export default TextArea

View File

@ -1,71 +1,154 @@
import dynamic from 'next/dynamic' import dynamic from 'next/dynamic'
import { NextSeo } from 'next-seo' import { NextSeo } from 'next-seo'
import { botCategories, botCategoryIcon, serverCategories, serverCategoryIcon } from '@utils/Constants' import {
botCategories,
botCategoryIcon,
serverCategories,
serverCategoryIcon,
} from '@utils/Constants'
const Container = dynamic(()=> import('@components/Container')) const Container = dynamic(() => import('@components/Container'))
const Tag = dynamic(()=> import('@components/Tag')) const Tag = dynamic(() => import('@components/Tag'))
const Search = dynamic(()=> import('@components/Search')) const Search = dynamic(() => import('@components/Search'))
const Hero:React.FC<HeroProps> = ({ type='all', header, description }) => { const Hero: React.FC<HeroProps> = ({ type = 'all', header, description }) => {
const link = `/${type}/categories` const link = `/${type}/categories`
return <> return (
<NextSeo title={header} description={description} openGraph={{ <>
title: header, <NextSeo
description title={header}
}} /> description={description}
<div className='dark:bg-discord-black bg-discord-blurple text-gray-100 md:p-0 mb-8'> openGraph={{
<Container className='pt-24 pb-16 md:pb-20' ignoreColor> title: header,
<h1 className='hidden md:block text-left text-3xl font-bold'> description,
{ header && `${header} - `} }}
</h1> />
<h1 className='md:hidden text-center text-3xl font-semibold'> <div className='mb-8 bg-discord-blurple text-gray-100 dark:bg-discord-black md:p-0'>
{ header && <span className='text-4xl'>{header}<br/></span>} <Container className='pb-16 pt-24 md:pb-20' ignoreColor>
</h1> <h1 className='hidden text-left text-3xl font-bold md:block'>
<p className='text-center sm:text-left text-xl font-base mt-2'>{description || `${type !== 'all' ? '다양한 ' : ''}국내 디스코드${{ all: '의 모든 것을', bots: ' 봇들을', servers: ' 서버들을' }[type]} 한 곳에서 확인하세요!`}</p> {header && `${header} - `}
<Search /> </h1>
<div className='flex flex-wrap mt-5'> <h1 className='text-center text-3xl font-semibold md:hidden'>
{ {header && (
type === 'all' ? <> <span className='text-4xl'>
<Tag text={ {header}
<> <br />
<i className='fas fa-robot text-koreanbots-blue'/> </span>
</> )}
} dark bigger href='/bots' />
<Tag text={ </h1>
<> <p className='font-base mt-2 text-center text-xl sm:text-left'>
<i className='fas fa-users text-koreanbots-blue'/> {description ||
</> `${type !== 'all' ? '다양한 ' : ''}국내 디스코드${
} dark bigger href='/servers' /> { all: '의 모든 것을', bots: ' 봇들을', servers: ' 서버들을' }[type]
{ } !`}
botCategories.slice(0, 2).map(t => <Tag key={t} text={<><i className={botCategoryIcon[t]} /> {t} </>} dark bigger href={`/bots/categories/${t}`} />) </p>
} <Search />
<div className='mt-5 flex flex-wrap'>
{ {type === 'all' ? (
serverCategories.slice(0, 2).map(t => <Tag key={t} text={<><i className={serverCategoryIcon[t]} /> {t} </>} dark bigger href={`/servers/categories/${t}`} />) <>
} <Tag
</>: <> text={
<Tag key='list' text={<> <>
<i className='fas fa-heart text-red-600'/> <i className='fas fa-robot text-koreanbots-blue' />
</>} 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
</>} dark bigger href={`${link}/${t}`} />) } bigger
<Tag key='tag' text={<> href='/bots'
<i className='fas fa-tag'/> />
</>} dark bigger href={link} /> <Tag
</> text={
} <>
</div> <i className='fas fa-users text-koreanbots-blue' />
</Container> </>
</div> }
</> 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>
</>
)
} }
interface HeroProps { interface HeroProps {
type?: 'all' | 'bots' | 'servers' type?: 'all' | 'bots' | 'servers'
header?: string header?: string
description?: string description?: string
} }
export default Hero export default Hero

View File

@ -2,45 +2,46 @@ import { SyntheticEvent, useEffect, useState } from 'react'
import { supportsWebP } from '@utils/Tools' import { supportsWebP } from '@utils/Tools'
import Logger from '@utils/Logger' import Logger from '@utils/Logger'
const BaseImage: React.FC<ImageProps> = props => { const BaseImage: React.FC<ImageProps> = (props) => {
const fallback = '/img/default.png' const fallback = '/img/default.png'
const [ webpUnavailable, setWebpUnavailable ] = useState<boolean>() const [webpUnavailable, setWebpUnavailable] = useState<boolean>()
useEffect(()=> { useEffect(() => {
setWebpUnavailable(localStorage.webp === 'false') setWebpUnavailable(localStorage.webp === 'false')
}, []) }, [])
return <img return (
alt={props.alt ?? 'Image'} <img
loading='lazy' alt={props.alt ?? 'Image'}
className={props.className} loading='lazy'
src={ className={props.className}
webpUnavailable && props.fallbackSrc || props.src src={(webpUnavailable && props.fallbackSrc) || props.src}
} onError={(e: SyntheticEvent<HTMLImageElement, ImageEvent>) => {
onError={(e: SyntheticEvent<HTMLImageElement, ImageEvent>)=> { if (webpUnavailable) {
if(webpUnavailable) { ;(e.target as ImageTarget).onerror = (event) => {
(e.target as ImageTarget).onerror = (event) => { // All Fails
// All Fails ;(event.target as ImageTarget).onerror = () => {
(event.target as ImageTarget).onerror = () => { Logger.warn('FALLBACK IMAGE LOAD FAIL') } Logger.warn('FALLBACK IMAGE LOAD FAIL')
(event.target as ImageTarget).src = fallback }
;(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
} }
} }}
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 { interface ImageProps {

View File

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

View File

@ -9,9 +9,7 @@ const Login: React.FC<React.PropsWithChildren> = ({ children }) => {
localStorage.redirectTo = window.location.href localStorage.redirectTo = window.location.href
redirectTo(router, 'login') redirectTo(router, 'login')
}) })
return <> return <>{children}</>
{children}
</>
} }
export default Login export default Login

View File

@ -1,42 +1,68 @@
/* eslint-disable jsx-a11y/no-static-element-interactions */ /* eslint-disable jsx-a11y/no-static-element-interactions */
import Link from 'next/link' import Link from 'next/link'
const LongButton: React.FC<LongButtonProps> = ({ children, newTab=false, href, onClick, center=false }) => { const LongButton: React.FC<LongButtonProps> = ({
if(href) { children,
if(newTab) return <a href={href} rel='noopener noreferrer' newTab = false,
target='_blank'> href,
<div className={`${center ? 'justify-center ': '' }text-base bg-little-white dark:bg-discord-black text-black dark:text-gray-400 rounded flex hover:bg-little-white-hover dark:hover:bg-discord-dark-hover cursor-pointer px-4 py-4 mb-1`}> onClick,
center = false,
}) => {
if (href) {
if (newTab)
return (
<a href={href} rel='noopener noreferrer' target='_blank'>
<div
className={`${
center ? 'justify-center ' : ''
}text-base mb-1 flex cursor-pointer rounded bg-little-white px-4 py-4 text-black hover:bg-little-white-hover dark:bg-discord-black dark:text-gray-400 dark:hover:bg-discord-dark-hover`}
>
{children}
</div>
</a>
)
else
return (
<Link
href={href}
className={`${
center ? 'justify-center ' : ''
}text-base mb-1 flex cursor-pointer rounded bg-little-white px-4 py-4 text-black hover:bg-little-white-hover dark:bg-discord-black dark:text-gray-400 dark:hover:bg-discord-dark-hover`}
>
{children}
</Link>
)
}
if (onClick)
return (
<div
onKeyPress={onClick}
onClick={onClick}
className={`${
center ? 'justify-center ' : ''
}text-base mb-1 flex cursor-pointer rounded bg-little-white px-4 py-4 text-black hover:bg-little-white-hover dark:bg-discord-black dark:text-gray-400 dark:hover:bg-discord-dark-hover`}
>
{children} {children}
</div> </div>
</a>
else return (
<Link
href={href}
className={`${center ? 'justify-center ': '' }text-base bg-little-white dark:bg-discord-black text-black dark:text-gray-400 rounded flex hover:bg-little-white-hover dark:hover:bg-discord-dark-hover cursor-pointer px-4 py-4 mb-1`}>
{children}
</Link>
) )
}
if(onClick) return <div onKeyPress={onClick} onClick={onClick} className={`${center ? 'justify-center ': '' }text-base bg-little-white dark:bg-discord-black text-black dark:text-gray-400 rounded flex hover:bg-little-white-hover dark:hover:bg-discord-dark-hover cursor-pointer px-4 py-4 mb-1`}>
{children}
</div>
return <a className={`${center ? 'justify-center ': '' }text-base bg-little-white dark:bg-discord-black text-black dark:text-gray-400 rounded flex hover:bg-little-white-hover dark:hover:bg-discord-dark-hover cursor-pointer px-4 py-4 mb-1`}>
{children}
</a>
return (
<a
className={`${
center ? 'justify-center ' : ''
}text-base mb-1 flex cursor-pointer rounded bg-little-white px-4 py-4 text-black hover:bg-little-white-hover dark:bg-discord-black dark:text-gray-400 dark:hover:bg-discord-dark-hover`}
>
{children}
</a>
)
} }
export default LongButton export default LongButton
interface LongButtonProps { interface LongButtonProps {
newTab?: boolean newTab?: boolean
onClick?: (event: React.KeyboardEvent<HTMLDivElement>|React.MouseEvent<HTMLDivElement>) => void onClick?: (event: React.KeyboardEvent<HTMLDivElement> | React.MouseEvent<HTMLDivElement>) => void
children: string | JSX.Element | JSX.Element[] children: string | JSX.Element | JSX.Element[]
href?: string href?: string
center?: boolean center?: boolean
} }

View File

@ -5,7 +5,12 @@ import * as Emoji from 'node-emoji'
import { anchorHeader, customEmoji, twemoji } from '@utils/Tools' import { anchorHeader, customEmoji, twemoji } from '@utils/Tools'
const Markdown: React.FC<MarkdownProps> = ({ text, options={}, allowedTag=[], components={} }) => { const Markdown: React.FC<MarkdownProps> = ({
text,
options = {},
allowedTag = [],
components = {},
}) => {
return ( return (
<div className='markdown-body w-full'> <div className='markdown-body w-full'>
<MarkdownView <MarkdownView
@ -23,10 +28,10 @@ const Markdown: React.FC<MarkdownProps> = ({ text, options={}, allowedTag=[], co
tasklists: true, tasklists: true,
ghCompatibleHeaderId: true, ghCompatibleHeaderId: true,
encodeEmails: true, encodeEmails: true,
...options ...options,
}} }}
components={components} components={components}
sanitizeHtml={html => sanitizeHtml={(html) =>
sanitizeHtml(html, { sanitizeHtml(html, {
allowedTags: [ allowedTags: [
'addr', 'addr',
@ -98,16 +103,16 @@ const Markdown: React.FC<MarkdownProps> = ({ text, options={}, allowedTag=[], co
'svg', 'svg',
'path', 'path',
'input', 'input',
...allowedTag ...allowedTag,
], ],
allowedAttributes: false, allowedAttributes: false,
allowedClasses: { allowedClasses: {
'*': ['align-middle'], '*': ['align-middle'],
a: ['anchor', 'mr-1'], a: ['anchor', 'mr-1'],
svg: ['octicon-link'], svg: ['octicon-link'],
img: ['emoji', 'special'] img: ['emoji', 'special'],
}, },
allowedStyles: {} allowedStyles: {},
}) })
} }
/> />

View File

@ -4,9 +4,9 @@ import Markdown from './Markdown'
const Message: React.FC<MessageProps> = ({ type, children }) => { const Message: React.FC<MessageProps> = ({ type, children }) => {
return ( return (
<div <div
className={`${MessageColor[type]} px-6 py-4 rounded-md text-base mx-auto w-full text-left`} className={`${MessageColor[type]} mx-auto w-full rounded-md px-6 py-4 text-left text-base`}
> >
{ typeof children === 'string' ? <Markdown text={children} /> : children } {typeof children === 'string' ? <Markdown text={children} /> : children}
</div> </div>
) )
} }

View File

@ -2,7 +2,15 @@ import { ReactNode } from 'react'
import { Modal as ReactModal } from 'react-responsive-modal' import { Modal as ReactModal } from 'react-responsive-modal'
import 'react-responsive-modal/styles.css' import 'react-responsive-modal/styles.css'
const Modal: React.FC<ModalProps> = ({ children, isOpen, onClose, closeIcon=false, dark, header, full=false }) => { const Modal: React.FC<ModalProps> = ({
children,
isOpen,
onClose,
closeIcon = false,
dark,
header,
full = false,
}) => {
return ( return (
<ReactModal <ReactModal
open={isOpen} open={isOpen}
@ -12,13 +20,13 @@ const Modal: React.FC<ModalProps> = ({ children, isOpen, onClose, closeIcon=fals
showCloseIcon={closeIcon} showCloseIcon={closeIcon}
styles={{ styles={{
closeButton: { closeButton: {
color: dark ? 'white' : 'black' color: dark ? 'white' : 'black',
}, },
modal: { modal: {
borderRadius: '10px', borderRadius: '10px',
background: dark ? '#2C2F33' : '#fbfbfb', background: dark ? '#2C2F33' : '#fbfbfb',
color: dark ? 'white' : 'black', color: dark ? 'white' : 'black',
width: full ? '90%' : 'inherit' width: full ? '90%' : 'inherit',
}, },
}} }}
> >

View File

@ -4,30 +4,43 @@ const Button = dynamic(() => import('@components/Button'))
const Container = dynamic(() => import('@components/Container')) const Container = dynamic(() => import('@components/Container'))
const NSFW: React.FC<NSFWProps> = ({ onClick, onDisableClick }) => { const NSFW: React.FC<NSFWProps> = ({ onClick, onDisableClick }) => {
return <Container> return (
<div className='flex items-center h-screen select-none'> <Container>
<div className='px-10'> <div className='flex h-screen select-none items-center'>
<h1 className='text-2xl font-bold flex'> <div className='px-10'>
<img draggable='false' alt='⚠' src='https://cdnjs.cloudflare.com/ajax/libs/twemoji/14.0.2/svg/26a0.svg' className='emoji mr-2 w-8' /> <h1 className='flex text-2xl font-bold'>
19 .</h1> <img
<p className='text-lg mb-3'>?</p> draggable='false'
<Button onClick={onClick}> alt='⚠'
<i className='fas fa-arrow-right' /> src='https://cdnjs.cloudflare.com/ajax/libs/twemoji/14.0.2/svg/26a0.svg'
</Button> className='emoji mr-2 w-8'
<div className='mt-1'> />
<button className='text-blue-500 hover:text-blue-600' onClick={() => { 19 .
onClick() </h1>
onDisableClick() <p className='mb-3 text-lg'>?</p>
}}> .</button> <Button onClick={onClick}>
<i className='fas fa-arrow-right' />
</Button>
<div className='mt-1'>
<button
className='text-blue-500 hover:text-blue-600'
onClick={() => {
onClick()
onDisableClick()
}}
>
.
</button>
</div>
</div> </div>
</div> </div>
</div> </Container>
</Container> )
} }
interface NSFWProps { interface NSFWProps {
onClick(): void onClick(): void
onDisableClick(): void onDisableClick(): void
} }
export default NSFW export default NSFW

View File

@ -20,309 +20,355 @@ const Navbar: React.FC<NavbarProps> = ({ token }) => {
const [mobileAddDropdownOpen, setMobileAddDropdownOpen] = useState<boolean>(false) const [mobileAddDropdownOpen, setMobileAddDropdownOpen] = useState<boolean>(false)
const router = useRouter() const router = useRouter()
const logged = userCache?.id && userCache.version === 2 const logged = userCache?.id && userCache.version === 2
const type: Nullable<'bot'|'server'> = router.pathname.startsWith('/bots') ? 'bot' : router.pathname.startsWith('/servers') ? 'server' : null const type: Nullable<'bot' | 'server'> = router.pathname.startsWith('/bots')
? 'bot'
: router.pathname.startsWith('/servers')
? 'server'
: null
const dev = router.pathname.startsWith('/developers') const dev = router.pathname.startsWith('/developers')
useEffect(() => { useEffect(() => {
try { try {
if(localStorage.userCache) { if (localStorage.userCache) {
setUserCache(token ? JSON.parse(localStorage.userCache) : null) setUserCache(token ? JSON.parse(localStorage.userCache) : null)
} }
Fetch<User>('/users/@me').then(data => { Fetch<User>('/users/@me').then((data) => {
if(data.code !== 200) return if (data.code !== 200) return
setUserCache(JSON.parse(localStorage.userCache = JSON.stringify({ setUserCache(
id: data.data.id, JSON.parse(
username: data.data.globalName, (localStorage.userCache = JSON.stringify({
tag: data.data.tag, id: data.data.id,
version: 2 username: data.data.globalName,
}))) tag: data.data.tag,
version: 2,
}))
)
)
}) })
} catch { } catch {
setUserCache(null) setUserCache(null)
} }
}, [ token ]) }, [token])
return <> return (
<nav className='fixed z-40 top-0 flex flex-wrap items-center justify-between px-2 py-3 w-full text-gray-100 dark:bg-discord-black bg-discord-blurple lg:absolute'> <>
<div className='container flex flex-wrap items-center justify-between mx-auto px-4'> <nav className='fixed top-0 z-40 flex w-full flex-wrap items-center justify-between bg-discord-blurple px-2 py-3 text-gray-100 dark:bg-discord-black lg:absolute'>
<div className='relative flex justify-between w-full lg:justify-start lg:w-auto'> <div className='container mx-auto flex flex-wrap items-center justify-between px-4'>
<Link <div className='relative flex w-full justify-between lg:w-auto lg:justify-start'>
href={dev ? '/developers' : '/'} <Link
className={`${dev ? 'dark:text-koreanbots-blue ' : ''}logofont text-large whitespace-no-wrap inline-block mr-4 py-2 hover:text-gray-300 font-semibold leading-relaxed uppercase sm:text-2xl`}> href={dev ? '/developers' : '/'}
className={`${
{ dev ? <><i className='fas fa-tools mr-1'/> DEVELOPERS</> : 'KOREANLIST'} dev ? 'dark:text-koreanbots-blue ' : ''
}logofont text-large whitespace-no-wrap mr-4 inline-block py-2 font-semibold uppercase leading-relaxed hover:text-gray-300 sm:text-2xl`}
</Link> >
<button {dev ? (
className='block px-3 py-1 dark:text-gray-200 text-xl leading-none bg-transparent border border-solid border-transparent rounded outline-none focus:outline-none cursor-pointer lg:hidden' <>
type='button' <i className='fas fa-tools mr-1' /> DEVELOPERS
onClick={() => setNavbarOpen(!navbarOpen)} </>
> ) : (
<i className={`fas ${!navbarOpen ? 'fa-bars' : 'fa-times'}`}></i> 'KOREANLIST'
</button> )}
<ul className='hidden lg:flex flex-col list-none lg:flex-row lg:ml-auto'> </Link>
<li className='flex items-center'> <button
<Link className='block cursor-pointer rounded border border-solid border-transparent bg-transparent px-3 py-1 text-xl leading-none outline-none focus:outline-none dark:text-gray-200 lg:hidden'
href={dev ? '/' : '/developers'} type='button'
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'> onClick={() => setNavbarOpen(!navbarOpen)}
>
{dev ? '홈' : '개발자'} <i className={`fas ${!navbarOpen ? 'fa-bars' : 'fa-times'}`}></i>
</button>
</Link> <ul className='hidden list-none flex-col lg:ml-auto lg:flex lg:flex-row'>
</li> <li className='flex items-center'>
{
type !== 'bot' && <li className='flex items-center'>
<Link <Link
href='/bots' href={dev ? '/' : '/developers'}
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'> className='flex w-full items-center px-3 py-4 text-sm font-semibold text-gray-700 hover:text-gray-500 sm:w-auto lg:py-2 lg:text-gray-100 lg:hover:text-gray-300'
>
{dev ? '홈' : '개발자'}
</Link> </Link>
</li> </li>
} {type !== 'bot' && (
{ <li className='flex items-center'>
type !== 'server' && <li className='flex items-center'> <Link
href='/bots'
className='flex w-full items-center px-3 py-4 text-sm font-semibold text-gray-700 hover:text-gray-500 sm:w-auto lg:py-2 lg:text-gray-100 lg:hover:text-gray-300'
>
</Link>
</li>
)}
{type !== 'server' && (
<li className='flex items-center'>
<Link
href='/servers'
className='flex w-full items-center px-3 py-4 text-sm font-semibold text-gray-700 hover:text-gray-500 sm:w-auto lg:py-2 lg:text-gray-100 lg:hover:text-gray-300'
>
</Link>
</li>
)}
<li className='flex items-center'>
<Link <Link
href='/servers' href='/discord'
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'> target='_blank'
rel='noreferrer'
className='flex w-full items-center px-3 py-4 text-sm font-semibold text-gray-700 hover:text-gray-500 sm:w-auto lg:py-2 lg:text-gray-100 lg:hover:text-gray-300'
>
</Link> </Link>
</li> </li>
} <li className='flex items-center'>
<li className='flex items-center'> <Link
<Link href='/about'
href='/discord' className='flex w-full items-center px-3 py-4 text-sm font-semibold text-gray-700 hover:text-gray-500 sm:w-auto lg:py-2 lg:text-gray-100 lg:hover:text-gray-300'
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'> </Link>
</li>
<li
className='flex items-center'
</Link> onFocus={() => setAddDropdownOpen(true)}
</li> onMouseOver={() => setAddDropdownOpen(true)}
<li className='flex items-center'> onMouseOut={() => setAddDropdownOpen(false)}
<Link onBlur={() => setAddDropdownOpen(false)}
href='/about' >
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'> <span className='flex w-full cursor-pointer items-center px-3 py-4 text-sm font-semibold text-gray-700 hover:text-gray-500 sm:w-auto lg:py-2 lg:text-gray-100 lg:hover:text-gray-300'>
</span>
<div
</Link> className={`absolute top-0 mt-11 w-40 rounded bg-white text-sm text-black shadow-md dark:bg-very-black dark:text-gray-300 ${
</li> addDropdownOpen ? 'block' : 'hidden'
<li className='flex items-center' onFocus={() => setAddDropdownOpen(true)} onMouseOver={() => setAddDropdownOpen(true)} onMouseOut={() => setAddDropdownOpen(false)} onBlur={() => setAddDropdownOpen(false)}> }`}
<span 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 cursor-pointer'> >
<ul className='relative'>
</span> <li>
<div className={`rounded shadow-md absolute mt-11 top-0 w-40 bg-white text-black dark:bg-very-black dark:text-gray-300 text-sm ${addDropdownOpen ? 'block' : 'hidden'}`}> <Link
<ul className='relative'> href='/addbot'
<li> className='block rounded-t px-4 py-2 hover:bg-gray-100 dark:hover:bg-discord-dark-hover'
<Link >
href='/addbot' <i className='fas fa-robot' />
className='px-4 py-2 block hover:bg-gray-100 dark:hover:bg-discord-dark-hover rounded-t'> </Link>
<i className='fas fa-robot' /> </li>
</Link> <li>
</li> <Link
<li> href='/addserver'
<Link className='block rounded-b px-4 py-2 hover:bg-gray-100 dark:hover:bg-discord-dark-hover'
href='/addserver' >
className='px-4 py-2 block hover:bg-gray-100 dark:hover:bg-discord-dark-hover rounded-b'> <i className='fas fa-users' />
<i className='fas fa-users' />
</Link> </Link>
</li> </li>
</ul> </ul>
</div> </div>
</li> </li>
</ul> </ul>
</div> </div>
<div className='hidden grow items-center bg-white lg:flex lg:bg-transparent lg:shadow-none'> <div className='hidden grow items-center bg-white lg:flex lg:bg-transparent lg:shadow-none'>
<ul className='flex flex-col list-none lg:flex-row lg:ml-auto'> <ul className='flex list-none flex-col lg:ml-auto lg:flex-row'>
<li className='flex items-center outline-none' onFocus={() => setDropdownOpen(true)} onMouseOver={() => setDropdownOpen(true)} onMouseOut={() => setDropdownOpen(false)} onBlur={() => setDropdownOpen(false)}> <li
{ className='flex items-center outline-none'
logged ? onFocus={() => setDropdownOpen(true)}
onMouseOver={() => setDropdownOpen(true)}
onMouseOut={() => setDropdownOpen(false)}
onBlur={() => setDropdownOpen(false)}
>
{logged ? (
<> <>
<a <a className='flex w-full cursor-pointer items-center px-3 py-4 text-sm font-semibold text-gray-700 hover:text-gray-500 sm:w-auto lg:py-2 lg:text-gray-100 lg:hover:text-gray-300'>
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 cursor-pointer'> <DiscordAvatar
<DiscordAvatar userID={userCache.id} className='w-8 h-8 rounded-full mr-1.5' size={128}/> userID={userCache.id}
{userCache.username} <i className='ml-2 fas fa-sort-down' /> className='mr-1.5 h-8 w-8 rounded-full'
size={128}
/>
{userCache.username} <i className='fas fa-sort-down ml-2' />
</a> </a>
<div className={`rounded shadow-md absolute mt-14 top-0 w-48 bg-white text-black dark:bg-very-black dark:text-gray-300 text-sm ${dropdownOpen ? 'block' : 'hidden'}`}> <div
className={`absolute top-0 mt-14 w-48 rounded bg-white text-sm text-black shadow-md dark:bg-very-black dark:text-gray-300 ${
dropdownOpen ? 'block' : 'hidden'
}`}
>
<ul className='relative'> <ul className='relative'>
<li> <li>
<Link <Link
href={`/users/${userCache.id}`} href={`/users/${userCache.id}`}
className='px-4 py-2 block hover:bg-gray-100 dark:hover:bg-discord-dark-hover rounded-t'> className='block rounded-t px-4 py-2 hover:bg-gray-100 dark:hover:bg-discord-dark-hover'
<i className='fas fa-user' /> >
<i className='fas fa-user' />
</Link> </Link>
</li> </li>
<li> <li>
<Link <Link
href='/panel' href='/panel'
className='px-4 py-2 block hover:bg-gray-100 dark:hover:bg-discord-dark-hover'> className='block px-4 py-2 hover:bg-gray-100 dark:hover:bg-discord-dark-hover'
<i className='fas fa-cogs' /> >
<i className='fas fa-cogs' />
</Link> </Link>
</li> </li>
{/* <li><hr className='border-t mx-2'/></li> */} {/* <li><hr className='border-t mx-2'/></li> */}
<li> <li>
<a onKeyPress={() => { <a
localStorage.removeItem('userCache') onKeyPress={() => {
redirectTo(router, 'logout') localStorage.removeItem('userCache')
} redirectTo(router, 'logout')
} onClick={() => { }}
localStorage.removeItem('userCache') onClick={() => {
redirectTo(router, 'logout') localStorage.removeItem('userCache')
}} className='px-4 py-2 block text-red-500 hover:bg-gray-100 dark:hover:bg-discord-dark-hover rounded-b cursor-pointer'><i className='fas fa-sign-out-alt' /> </a> redirectTo(router, 'logout')
}}
className='block cursor-pointer rounded-b px-4 py-2 text-red-500 hover:bg-gray-100 dark:hover:bg-discord-dark-hover'
>
<i className='fas fa-sign-out-alt' />
</a>
</li> </li>
</ul> </ul>
</div> </div>
</> : </>
<a tabIndex={0} onClick={()=> { ) : (
localStorage.redirectTo = window.location.href <a
setNavbarOpen(false) tabIndex={0}
redirectTo(router, 'login') onClick={() => {
}} 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 cursor-pointer outline-none'> localStorage.redirectTo = window.location.href
setNavbarOpen(false)
redirectTo(router, 'login')
}}
className='flex w-full cursor-pointer items-center px-3 py-4 text-sm font-semibold text-gray-700 outline-none hover:text-gray-500 sm:w-auto lg:py-2 lg:text-gray-100 lg:hover:text-gray-300'
>
</a> </a>
} )}
</li> </li>
</ul> </ul>
</div> </div>
</div>
</nav>
<div
className={`z-30 w-full h-full fixed bg-discord-blurple dark:bg-discord-black mt-8 sm:mt-0 lg:hidden overflow-y-scroll lg:scroll-none ${
navbarOpen ? 'block' : 'hidden'
}`}
>
<nav className='mt-20'>
<Link
href={dev ? '/' : '/developers'}
onClick={()=> setNavbarOpen(false)}
className='flex items-center px-8 py-2 text-gray-100 hover:text-gray-300'>
{
dev ? <i className='fas fa-home' /> : <i className='fas fa-tools' />
}
<span className='px-2 font-medium'>
{dev ? '홈' : '개발자'}
</span>
</Link>
{
type !== 'bot' && <Link
href='/bots'
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>
</Link>
}
{
type !== 'server' && <Link
href='/servers'
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>
</Link>
}
<Link
href='/discord'
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' />
<span className='px-2 font-medium'> </span>
</Link>
<Link
href='/about'
onClick={()=> setNavbarOpen(false)}
className='flex items-center px-8 py-2 text-gray-100 hover:text-gray-300'>
<i className='fas fa-layer-group' />
<span className='px-2 font-medium'></span>
</Link>
<a
onClick={()=> {
setMobileAddDropdownOpen(!mobileAddDropdownOpen)
}}
className='flex items-center px-8 py-2 text-gray-100'
>
<i className='fas fa-plus' />
<span className='px-2 font-medium'></span>
</a>
<div className={mobileAddDropdownOpen ? 'px-4 flex flex-col' : 'px-4 hidden'}>
<Link
href='/addbot'
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>
</Link>
<Link
href='/addserver'
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>
</Link>
</div> </div>
</nav> </nav>
<div
<div className='my-10'> className={`lg:scroll-none fixed z-30 mt-8 h-full w-full overflow-y-scroll bg-discord-blurple dark:bg-discord-black sm:mt-0 lg:hidden ${
{ navbarOpen ? 'block' : 'hidden'
logged ? <> }`}
>
<nav className='mt-20'>
<Link
href={dev ? '/' : '/developers'}
onClick={() => setNavbarOpen(false)}
className='flex items-center px-8 py-2 text-gray-100 hover:text-gray-300'
>
{dev ? <i className='fas fa-home' /> : <i className='fas fa-tools' />}
<span className='px-2 font-medium'>{dev ? '홈' : '개발자'}</span>
</Link>
{type !== 'bot' && (
<Link <Link
href={`/users/${userCache.id}`} href='/bots'
onClick={() => setNavbarOpen(false)}
className='flex items-center px-8 py-2 text-gray-100 hover:text-gray-300' className='flex items-center px-8 py-2 text-gray-100 hover:text-gray-300'
onClick={() => setNavbarOpen(!navbarOpen)}> >
<i className='fas fa-robot' />
<i className='far fa-user' /> <span className='px-2 font-medium'> </span>
<span className='px-2 font-medium'>{userCache.username}</span>
</Link> </Link>
)}
{type !== 'server' && (
<Link <Link
href='/panel' href='/servers'
onClick={() => setNavbarOpen(false)}
className='flex items-center px-8 py-2 text-gray-100 hover:text-gray-300' className='flex items-center px-8 py-2 text-gray-100 hover:text-gray-300'
onClick={() => setNavbarOpen(!navbarOpen)}> >
<i className='fas fa-users' />
<i className='fas fa-cogs' /> <span className='px-2 font-medium'> </span>
<span className='px-2 font-medium'></span>
</Link> </Link>
<a onClick={()=> { )}
setNavbarOpen(!navbarOpen) <Link
localStorage.removeItem('userCache') href='/discord'
redirectTo(router, 'logout') target='_blank'
}} className='flex items-center px-8 py-2 text-red-500 hover:text-red-400'> rel='noreferrer'
<i className='fas fa-sign-out-alt' /> onClick={() => setNavbarOpen(false)}
<span className='px-2 font-medium'></span> className='flex items-center px-8 py-2 text-gray-100 hover:text-gray-300'
</a> >
</> : <a onClick={() => { <i className='fab fa-discord' />
localStorage.redirectTo = window.location.href <span className='px-2 font-medium'> </span>
setNavbarOpen(false) </Link>
redirectTo(router, 'login') <Link
}} className='flex items-center px-8 py-2 text-gray-100 hover:text-gray-300'> href='/about'
<i className='far fa-user' /> onClick={() => setNavbarOpen(false)}
<span className='px-2 font-medium'></span> className='flex items-center px-8 py-2 text-gray-100 hover:text-gray-300'
>
<i className='fas fa-layer-group' />
<span className='px-2 font-medium'></span>
</Link>
<a
onClick={() => {
setMobileAddDropdownOpen(!mobileAddDropdownOpen)
}}
className='flex items-center px-8 py-2 text-gray-100'
>
<i className='fas fa-plus' />
<span className='px-2 font-medium'></span>
</a> </a>
} <div className={mobileAddDropdownOpen ? 'flex flex-col px-4' : 'hidden px-4'}>
<Link
href='/addbot'
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>
</Link>
<Link
href='/addserver'
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>
</Link>
</div>
</nav>
<div className='my-10'>
{logged ? (
<>
<Link
href={`/users/${userCache.id}`}
className='flex items-center px-8 py-2 text-gray-100 hover:text-gray-300'
onClick={() => setNavbarOpen(!navbarOpen)}
>
<i className='far fa-user' />
<span className='px-2 font-medium'>{userCache.username}</span>
</Link>
<Link
href='/panel'
className='flex items-center px-8 py-2 text-gray-100 hover:text-gray-300'
onClick={() => setNavbarOpen(!navbarOpen)}
>
<i className='fas fa-cogs' />
<span className='px-2 font-medium'></span>
</Link>
<a
onClick={() => {
setNavbarOpen(!navbarOpen)
localStorage.removeItem('userCache')
redirectTo(router, 'logout')
}}
className='flex items-center px-8 py-2 text-red-500 hover:text-red-400'
>
<i className='fas fa-sign-out-alt' />
<span className='px-2 font-medium'></span>
</a>
</>
) : (
<a
onClick={() => {
localStorage.redirectTo = window.location.href
setNavbarOpen(false)
redirectTo(router, 'login')
}}
className='flex items-center px-8 py-2 text-gray-100 hover:text-gray-300'
>
<i className='far fa-user' />
<span className='px-2 font-medium'></span>
</a>
)}
</div>
</div> </div>
</div> </>
</> )
} }
interface NavbarProps { interface NavbarProps {

View File

@ -1,6 +1,6 @@
const Notice: React.FC<NoticeProps> = ({ header, desc }) => { const Notice: React.FC<NoticeProps> = ({ header, desc }) => {
return ( return (
<div className='mx-auto my-auto px-10 py-48 h-screen text-center'> <div className='mx-auto my-auto h-screen px-10 py-48 text-center'>
<h1 className='text-4xl font-bold'>KOREANBOTS</h1> <h1 className='text-4xl font-bold'>KOREANBOTS</h1>
<br /> <br />
<div> <div>

View File

@ -1,21 +1,23 @@
import Link from 'next/link' import Link from 'next/link'
import DiscordAvatar from '@components/DiscordAvatar' import DiscordAvatar from '@components/DiscordAvatar'
const Owner: React.FC<OwnerProps> = ({ id, globalName, username, tag, crown=false }) => { const Owner: React.FC<OwnerProps> = ({ id, globalName, username, tag, crown = false }) => {
return ( return (
(<Link <Link
href={`/users/${id}`} href={`/users/${id}`}
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'> className='mb-1 flex cursor-pointer rounded bg-little-white px-4 py-4 text-base text-black hover:bg-little-white-hover dark:bg-discord-black dark:text-gray-400 dark:hover:bg-discord-dark-hover'
>
<div className='relative shrink-0 mr-3 mt-1 w-8 h-8 rounded-full shadow-inner overflow-hidden'> <div className='relative mr-3 mt-1 h-8 w-8 shrink-0 overflow-hidden rounded-full shadow-inner'>
<DiscordAvatar userID={id} className='z-negative absolute inset-0 w-full h-full' /> <DiscordAvatar userID={id} className='z-negative absolute inset-0 h-full w-full' />
</div> </div>
<div className='flex-1 w-0 leading-snug'> <div className='w-0 flex-1 leading-snug'>
<h4 className='whitespace-nowrap truncate'>{ crown && <i className='fas fa-crown text-amber-300 text-xs' /> }{tag === '0' ? globalName : username}</h4> <h4 className='truncate whitespace-nowrap'>
<span className='text-gray-600 text-sm'>{tag === '0' ? '@' + username : '#' + tag}</span> {crown && <i className='fas fa-crown text-xs text-amber-300' />}
{tag === '0' ? globalName : username}
</h4>
<span className='text-sm text-gray-600'>{tag === '0' ? '@' + username : '#' + tag}</span>
</div> </div>
</Link>
</Link>)
) )
} }

View File

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

View File

@ -1,13 +1,16 @@
import { ReactNode } from 'react' import { ReactNode } from 'react'
const PlatformDisplay: React.FC<PlatformDisplayProps> = ({ osx, children }:PlatformDisplayProps) => { const PlatformDisplay: React.FC<PlatformDisplayProps> = ({
osx,
children,
}: PlatformDisplayProps) => {
const isOSX = /(Mac|iPhone|iPod|iPad)/i.test(navigator.platform) const isOSX = /(Mac|iPhone|iPod|iPad)/i.test(navigator.platform)
return <>{isOSX ? osx ?? children : children}</> return <>{isOSX ? osx ?? children : children}</>
} }
interface PlatformDisplayProps { interface PlatformDisplayProps {
osx?: ReactNode osx?: ReactNode
children: ReactNode children: ReactNode
} }
export default PlatformDisplay export default PlatformDisplay

View File

@ -6,26 +6,28 @@ import { redirectTo } from '@utils/Tools'
const Container = dynamic(() => import('@components/Container')) const Container = dynamic(() => import('@components/Container'))
const Redirect: React.FC<RedirectProps> = ({ to, text=true, children }) => { const Redirect: React.FC<RedirectProps> = ({ to, text = true, children }) => {
const router = useRouter() const router = useRouter()
if(!to) throw new Error('No Link') if (!to) throw new Error('No Link')
useEffect(() => { useEffect(() => {
redirectTo(router, to) redirectTo(router, to)
}) })
if(children) return <> if (children) return <>{children}</>
{children} return (
</> <Container paddingTop>
return <Container paddingTop> <div>
<div> <a href={to} className='text-blue-400'>
<a href={to} className='text-blue-400'>{text && '자동으로 리다이렉트되지 않는다면 클릭하세요.'}</a> {text && '자동으로 리다이렉트되지 않는다면 클릭하세요.'}
</div> </a>
</Container> </div>
</Container>
)
} }
interface RedirectProps { interface RedirectProps {
to: string to: string
text?: boolean text?: boolean
children?: ReactNode children?: ReactNode
} }
export default Redirect export default Redirect

View File

@ -5,74 +5,123 @@ import { FormikErrors, FormikTouched } from 'formik'
const Button = dynamic(() => import('@components/Button')) const Button = dynamic(() => import('@components/Button'))
const TextArea = dynamic(() => import('@components/Form/TextArea')) const TextArea = dynamic(() => import('@components/Form/TextArea'))
export const Check: FC<{ checked: boolean, text: string }> = ({ checked, text }) => <> export const Check: FC<{ checked: boolean; text: string }> = ({ checked, text }) => (
{checked && <i className='text-emerald-400 fas fa-check-circle mr-1' />} <>
{text} {checked && <i className='fas fa-check-circle mr-1 text-emerald-400' />}
</> {text}
</>
)
export const SubmitButton: FC = () => <div className='text-right'> export const SubmitButton: FC = () => (
<Button type='submit'></Button> <div className='text-right'>
</div> <Button type='submit'></Button>
</div>
)
export const TextField: FC<ReportTemplateProps> = ({ values, errors, touched, setFieldValue }) => <> export const TextField: FC<ReportTemplateProps> = ({ values, errors, touched, setFieldValue }) => (
<TextArea name='description' placeholder='최대한 자세하게 설명해주세요!' value={values.description} setValue={(value) => setFieldValue('description', value)} /> <>
<div className='mt-1 text-red-500 text-xs font-light'>{errors.description && touched.description ? errors.description : null}</div> <TextArea
<SubmitButton /> name='description'
</> placeholder='최대한 자세하게 설명해주세요!'
value={values.description}
setValue={(value) => setFieldValue('description', value)}
/>
<div className='mt-1 text-xs font-light text-red-500'>
{errors.description && touched.description ? errors.description : null}
</div>
<SubmitButton />
</>
)
export const DMCA: FC<ReportTemplateProps> = ({ values, errors, touched, setFieldValue }) => { export const DMCA: FC<ReportTemplateProps> = ({ values, errors, touched, setFieldValue }) => {
const [ isOwner, setOwner ] = useState(null) const [isOwner, setOwner] = useState(null)
const [ contacted, setContacted ] = useState(null) const [contacted, setContacted] = useState(null)
return <div> return (
<h3 className='font-bold my-2'> ?</h3> <div>
<Button onClick={() => setOwner(true)}> <h3 className='my-2 font-bold'> ?</h3>
<Check checked={isOwner} text='권리자 본인 혹은 대리인입니다.' /> <Button onClick={() => setOwner(true)}>
</Button> <Check checked={isOwner} text='권리자 본인 혹은 대리인입니다.' />
<Button onClick={() => setOwner(false)}> </Button>
<Check checked={isOwner === false} text='권리자가 아닙니다.' /> <Button onClick={() => setOwner(false)}>
</Button> <Check checked={isOwner === false} text='권리자가 아닙니다.' />
{ </Button>
isOwner === true ? <> {isOwner === true ? (
<h3 className='font-bold my-2'> ?</h3> <>
<Button onClick={() => setContacted(true)}> <h3 className='my-2 font-bold'>
<Check checked={contacted} text='최대한 연락을 시도하였지만 개선되지 않았습니다.' /> ?
</Button> </h3>
<Button onClick={() => setContacted(false)}> <Button onClick={() => setContacted(true)}>
<Check checked={contacted === false} text='아니요, 아직 연락하지 않았습니다.' /> <Check checked={contacted} text='최대한 연락을 시도하였지만 개선되지 않았습니다.' />
</Button> </Button>
{ <Button onClick={() => setContacted(false)}>
contacted ? <div> <Check checked={contacted === false} text='아니요, 아직 연락하지 않았습니다.' />
<h3 className='font-bold mt-2'></h3> </Button>
<p className='text-gray-400 text-sm mb-1'> .</p> {contacted ? (
<ul className='text-gray-400 text-sm mb-1 list-disc list-inside'> <div>
<li> ( )</li> <h3 className='mt-2 font-bold'></h3>
<li> ( , )</li> <p className='mb-1 text-sm text-gray-400'> .</p>
</ul> <ul className='mb-1 list-inside list-disc text-sm text-gray-400'>
<p className='text-gray-400 text-sm mb-1'> <a className='text-blue-400' target='_blank' rel='noreferrer' href={`mailto:dmca@koreanbots.dev?subject=${encodeURI('[DMCA] 추가 컨텐츠')}&body=${encodeURI('디스코드 태그:')}`}>dmca@koreanbots.dev</a> , .</p> <li>
<TextField values={values} errors={errors} touched={touched} setFieldValue={setFieldValue} /> (
</div> )
: contacted === false ? <> </li>
<h2 className='font-bold mt-4 text-xl'> .</h2> <li> ( , )</li>
<p> , .</p> </ul>
</> : '' <p className='mb-1 text-sm text-gray-400'>
} {' '}
</> <a
: isOwner === false ? <> className='text-blue-400'
<h2 className='font-bold mt-4 text-xl'>, .</h2> target='_blank'
rel='noreferrer'
href={`mailto:dmca@koreanbots.dev?subject=${encodeURI(
'[DMCA] 추가 컨텐츠'
)}&body=${encodeURI('디스코드 태그:')}`}
>
dmca@koreanbots.dev
</a>
, .
</p>
<TextField
values={values}
errors={errors}
touched={touched}
setFieldValue={setFieldValue}
/>
</div>
) : contacted === false ? (
<>
<h2 className='mt-4 text-xl font-bold'> .</h2>
<p>
,
.
</p>
</>
) : (
''
)}
</>
) : isOwner === false ? (
<>
<h2 className='mt-4 text-xl font-bold'>
, .
</h2>
<p> , !</p> <p> , !</p>
</> : '' </>
} ) : (
</div> ''
)}
</div>
)
} }
interface ReportValues { interface ReportValues {
category: string | null category: string | null
description: string description: string
_csrf: string _csrf: string
} }
interface ReportTemplateProps { interface ReportTemplateProps {
values?: ReportValues values?: ReportValues
errors?: FormikErrors<ReportValues> errors?: FormikErrors<ReportValues>
touched?: FormikTouched<ReportValues> touched?: FormikTouched<ReportValues>
setFieldValue?(field: string, value: unknown): void setFieldValue?(field: string, value: unknown): void
} }

View File

@ -1,7 +1,9 @@
const ResponsiveGrid: React.FC<React.PropsWithChildren> = ({ children }) => { const ResponsiveGrid: React.FC<React.PropsWithChildren> = ({ children }) => {
return <div className='grid gap-x-4 grid-rows-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 mt-10 -mb-10'> return (
{children} <div className='-mb-10 mt-10 grid grid-rows-1 gap-x-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
</div> {children}
</div>
)
} }
export default ResponsiveGrid export default ResponsiveGrid

View File

@ -46,7 +46,7 @@ const Search: React.FC = () => {
const res = await Fetch<ListAll>(`/search/all?q=${encodeURIComponent(value)}`, { const res = await Fetch<ListAll>(`/search/all?q=${encodeURIComponent(value)}`, {
signal: controller.signal, signal: controller.signal,
}).catch((e) => { }).catch((e) => {
if(e.name !== 'AbortError') throw e if (e.name !== 'AbortError') throw e
else return else return
}) })
setData(res || {}) setData(res || {})
@ -54,23 +54,29 @@ const Search: React.FC = () => {
} }
const onSubmit = () => { const onSubmit = () => {
if(query.length < 2) return if (query.length < 2) return
if(!localStorage.recentSearch) localStorage.recentSearch = '[]' if (!localStorage.recentSearch) localStorage.recentSearch = '[]'
try { try {
const d = JSON.parse(localStorage.recentSearch).reverse() const d = JSON.parse(localStorage.recentSearch).reverse()
if(d.findIndex(n => n.value === query) !== -1) d.splice(d.findIndex(n => n.value === query), 1) if (d.findIndex((n) => n.value === query) !== -1)
d.splice(
d.findIndex((n) => n.value === query),
1
)
d.push({ d.push({
value: query, value: query,
date: Date.now() date: Date.now(),
}) })
d.reverse() d.reverse()
setRecentSearch(d.slice(0, 10)) setRecentSearch(d.slice(0, 10))
localStorage.recentSearch = JSON.stringify(d.slice(0, 10)) localStorage.recentSearch = JSON.stringify(d.slice(0, 10))
} catch { } catch {
setRecentSearch([{ setRecentSearch([
value: query, {
date: Date.now() value: query,
}]) date: Date.now(),
},
])
localStorage.recentSearch = JSON.stringify(recentSearch) localStorage.recentSearch = JSON.stringify(recentSearch)
} finally { } finally {
redirectTo(router, `/search/?q=${encodeURIComponent(query)}`) redirectTo(router, `/search/?q=${encodeURIComponent(query)}`)
@ -79,19 +85,17 @@ const Search: React.FC = () => {
return ( return (
<div onFocus={() => setHidden(false)} ref={ref}> <div onFocus={() => setHidden(false)} ref={ref}>
<div <div className='relative z-10 mt-5 flex w-full rounded-lg bg-white text-black dark:bg-very-black dark:text-gray-100'>
className='relative z-10 flex mt-5 w-full text-black dark:text-gray-100 dark:bg-very-black bg-white rounded-lg'
>
<input <input
type='search' type='search'
maxLength={50} maxLength={50}
className='grow pr-20 px-7 py-3 h-16 text-xl bg-transparent border-0 border-none outline-none shadow' className='h-16 grow border-0 border-none bg-transparent px-7 py-3 pr-20 text-xl shadow outline-none'
placeholder='검색...' placeholder='검색...'
value={query} value={query}
onChange={e => { onChange={(e) => {
SearchResults(e.target.value) SearchResults(e.target.value)
}} }}
onKeyDown={e => { onKeyDown={(e) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
onSubmit() onSubmit()
} }
@ -101,103 +105,116 @@ const Search: React.FC = () => {
className='cusor-pointer absolute right-0 top-0 mr-5 mt-5 outline-none' className='cusor-pointer absolute right-0 top-0 mr-5 mt-5 outline-none'
onClick={onSubmit} onClick={onSubmit}
> >
<i className='fas fa-search text-gray-600 hover:text-gray-700 text-2xl' /> <i className='fas fa-search text-2xl text-gray-600 hover:text-gray-700' />
</button> </button>
</div> </div>
<div className={`relative ${hidden ? 'hidden' : 'block'} z-50`}> <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'> <div className='pin-t pin-l absolute my-2 h-60 w-full overflow-y-scroll rounded bg-white text-black shadow-md dark:bg-very-black dark:text-gray-100 md:h-80'>
<ul> <ul>
{(data && data.code === 200) ? ( {data && data.code === 200 ? (
<div className='grid lg:grid-cols-2'> <div className='grid lg:grid-cols-2'>
<ul> <ul>
<li className='px-3 py-3.5 font-bold'></li> <li className='px-3 py-3.5 font-bold'></li>
{ {data.data.bots.length === 0 ? (
data.data.bots.length === 0 ? <li className='px-3 py-3.5'> .</li>
<li className='px-3 py-3.5'> .</li> : ) : (
data.data.bots.map(el => ( data.data.bots.map((el) => (
<Link key={el.id} href={makeBotURL(el)} legacyBehavior> <Link key={el.id} href={makeBotURL(el)} legacyBehavior>
<li className='h-15 flex px-3 py-2 cursor-pointer'> <li className='h-15 flex cursor-pointer px-3 py-2'>
<DiscordAvatar className='mt-1 w-12 h-12' size={128} userID={el.id} /> <DiscordAvatar className='mt-1 h-12 w-12' size={128} userID={el.id} />
<div className='ml-2'> <div className='ml-2'>
<h1 className='text-black dark:text-gray-100 text-lg'>{el.name}</h1> <h1 className='text-lg text-black dark:text-gray-100'>{el.name}</h1>
<p className='text-gray-400 text-sm'>{el.intro}</p> <p className='text-sm text-gray-400'>{el.intro}</p>
</div> </div>
</li>
</Link>
))
}
</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)} legacyBehavior>
<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)}`}
legacyBehavior>
<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> </li>
</Link> </Link>
)) ))
} )}
</> : </ul>
query.length < 3 ? ( <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)} legacyBehavior>
<li className='h-15 flex cursor-pointer px-3 py-2'>
<ServerIcon className='mt-1 h-12 w-12' size={128} id={el.id} />
<div className='ml-2'>
<h1 className='text-lg text-black dark:text-gray-100'>{el.name}</h1>
<p className='text-sm text-gray-400'>{el.intro}</p>
</div>
</li>
</Link>
))
)}
</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='text-blue-500 hover:text-blue-400'
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 cursor-pointer px-3 py-2 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)}`}
legacyBehavior
>
<li className='h-15 cursor-pointer px-3 py-2'>
<i className='fas fa-history' /> {el?.value}
<span className='absolute right-0 pr-10 text-sm text-gray-400'>
{Day(el?.date).format('MM.DD.')}
</span>
</li>
</Link>
))}
</>
)
) : query.length < 3 ? (
'최소 2글자 이상 입력해주세요.' '최소 2글자 이상 입력해주세요.'
) : ( ) : (
'검색어를 입력해주세요.' '검색어를 입력해주세요.'
)} )}
</ul> </ul>
} )}
</ul> </ul>
</div> </div>
</div> </div>
@ -208,6 +225,6 @@ const Search: React.FC = () => {
export default Search export default Search
interface ListAll { interface ListAll {
bots: Bot[], bots: Bot[]
servers: Server[] servers: Server[]
} }

View File

@ -3,7 +3,7 @@ import { ReactNode } from 'react'
const Segment: React.FC<SegmentProps> = ({ children, className = '' }) => { const Segment: React.FC<SegmentProps> = ({ children, className = '' }) => {
return ( return (
<div <div
className={`py-3 px-7 text-black dark:text-white dark:bg-discord-black bg-little-white rounded ${className}`} className={`rounded bg-little-white px-7 py-3 text-black dark:bg-discord-black dark:text-white ${className}`}
> >
{children} {children}
</div> </div>

View File

@ -10,43 +10,50 @@ const Tag = dynamic(() => import('@components/Tag'))
const ServerIcon = dynamic(() => import('@components/ServerIcon')) const ServerIcon = dynamic(() => import('@components/ServerIcon'))
const ServerCard: React.FC<BotCardProps> = ({ type, server }) => { 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` const newServerLink = server.data
? `/addserver/${server.id}`
: `${DiscordEnpoints.InviteApplication(
DSKR_BOT_ID,
{},
'bot',
null,
server.id
)}&disable_guild_select=true`
return ( return (
<div className='min-w-0 container mb-16 transform hover:-translate-y-1 transition duration-100 ease-in cursor-pointer'> <div className='container mb-16 min-w-0 transform cursor-pointer transition duration-100 ease-in hover:-translate-y-1'>
<div className='relative'> <div className='relative'>
<div className='container mx-auto'> <div className='container mx-auto'>
<div className='h-full'> <div className='h-full'>
<div <div
className='relative mx-auto h-full text-black dark:text-white dark:bg-discord-black bg-little-white rounded-2xl shadow-xl' className='relative mx-auto h-full rounded-2xl bg-little-white text-black shadow-xl dark:bg-discord-black dark:text-white'
style={ style={
checkServerFlag(server.flags, 'trusted') && server.banner 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`, 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', color: 'white',
} }
: {} : {}
} }
> >
<Link <Link href={type !== 'add' ? makeServerURL(server) : newServerLink} legacyBehavior>
href={type !== 'add' ? makeServerURL(server) : newServerLink}
legacyBehavior>
<div> <div>
<div className='flex flex-col'> <div className='flex flex-col'>
<div className='flex'> <div className='flex'>
<div className='w-3/5 flex justify-start'> <div className='flex w-3/5 justify-start'>
<ServerIcon <ServerIcon
size={128} size={128}
id={server.id} id={server.id}
hash={type === 'add' && server.icon} hash={type === 'add' && server.icon}
alt='Icon' alt='Icon'
className='absolute -left-2 -top-8 mx-auto w-32 h-32 bg-white rounded-full' className='absolute -left-2 -top-8 mx-auto h-32 w-32 rounded-full bg-white'
/> />
</div> </div>
<div className='grid grid-cols-1 pr-5 pt-5 w-2/5'> <div className='grid w-2/5 grid-cols-1 pr-5 pt-5'>
<Tag <Tag
text={ text={
<> <>
<i className='fas fa-heart text-red-600' /> {formatNumber(server.votes)} <i className='fas fa-heart text-red-600' />{' '}
{formatNumber(server.votes)}
</> </>
} }
dark dark
@ -58,26 +65,38 @@ const ServerCard: React.FC<BotCardProps> = ({ type, server }) => {
/> />
</div> </div>
</div> </div>
<div className='mt-3 px-4 h-16'> <div className='mt-3 h-16 px-4'>
<h2 className={`px-1 text-sm ${server.state !== 'unreachable' ? ' invisible' : ''}`}> <h2
<i className='fas fa-ban text-red-600' /> className={`px-1 text-sm ${
server.state !== 'unreachable' ? ' invisible' : ''
}`}
>
<i className='fas fa-ban text-red-600' />
</h2> </h2>
<h1 className='mb-3 text-left text-xl sm:text-2xl font-bold truncate'>{server.name}</h1> <h1 className='mb-3 truncate text-left text-xl font-bold sm:text-2xl'>
{server.name}
</h1>
</div> </div>
</div> </div>
<p className='mb-10 px-4 h-6 text-left text-gray-400 text-sm font'> <p className='font mb-10 h-6 px-4 text-left text-sm text-gray-400'>
{type === 'add' ? {type === 'add'
server.data ? '지금 바로 서버를 등록할 수 있습니다.' : '봇을 초대해야 서버를 등록할 수 있습니다.' ? server.data
: server.intro ? '지금 바로 서버를 등록할 수 있습니다.'
} : '봇을 초대해야 서버를 등록할 수 있습니다.'
: server.intro}
</p> </p>
<div> <div>
<div className='category flex flex-wrap px-2'> <div className='category flex flex-wrap px-2'>
{server.category?.slice(0, 3).map(el => ( {server.category
<Tag key={el} text={el} href={`/servers/categories/${el}`} dark /> ?.slice(0, 3)
))}{' '} .map((el) => (
{server.category?.length > 3 && <Tag text={`+${server.category.length - 3}`} dark />} <Tag key={el} text={el} href={`/servers/categories/${el}`} dark />
))}{' '}
{server.category?.length > 3 && (
<Tag text={`+${server.category.length - 3}`} dark />
)}
</div> </div>
</div> </div>
</div> </div>
@ -85,59 +104,55 @@ const ServerCard: React.FC<BotCardProps> = ({ type, server }) => {
<Divider /> <Divider />
<div className='w-full'> <div className='w-full'>
<div className='flex justify-evenly'> <div className='flex justify-evenly'>
{ {type === 'add' ? (
type === 'add' ? server.data ? (
server.data ? <Link <Link
href={newServerLink} href={newServerLink}
className='py-3 w-full text-center text-emerald-500 hover:text-white text-sm font-bold hover:bg-emerald-500 rounded-b-2xl hover:shadow-lg transition duration-100 ease-in'> className='w-full rounded-b-2xl py-3 text-center text-sm font-bold text-emerald-500 transition duration-100 ease-in hover:bg-emerald-500 hover:text-white hover:shadow-lg'
>
</Link> : <Link
href={newServerLink}
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'>
</Link> </Link>
: ) : (
<> <Link
href={newServerLink}
className='w-full rounded-b-2xl py-3 text-center text-sm font-bold text-discord-blurple transition duration-100 ease-in hover:bg-discord-blurple hover:text-white hover:shadow-lg'
rel='noopener noreferrer'
target='_blank'
>
</Link>
)
) : (
<>
<Link
href={makeServerURL(server)}
className='w-full rounded-bl-2xl py-3 text-center text-sm font-bold text-koreanbots-blue transition duration-100 ease-in hover:bg-koreanbots-blue hover:text-white hover:shadow-lg'
>
</Link>
{type === 'manage' ? (
<Link <Link
href={makeServerURL(server)} href={`/servers/${server.id}/edit`}
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'> className='w-full rounded-br-2xl py-3 text-center text-sm font-bold text-emerald-500 transition duration-100 ease-in hover:bg-emerald-500 hover:text-white hover:shadow-lg'
</Link>
{type === 'manage' ? (
<Link
href={`/servers/${server.id}/edit`}
className='py-3 w-full text-center text-emerald-500 hover:text-white text-sm font-bold hover:bg-emerald-500 rounded-br-2xl hover:shadow-lg transition duration-100 ease-in'>
</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> : </Link>
<a ) : !['ok', 'unreachable'].includes(server.state) ? (
href={ <a className='w-full cursor-default select-none rounded-br-2xl py-3 text-center text-sm font-bold text-discord-blurple opacity-50 transition duration-100 ease-in hover:shadow-lg'>
makeServerURL(server) + '/join'
} </a>
rel='noopener noreferrer' ) : (
target='_blank' <a
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' href={makeServerURL(server) + '/join'}
> rel='noopener noreferrer'
target='_blank'
</a> className='w-full rounded-br-2xl py-3 text-center text-sm font-bold text-discord-blurple transition duration-100 ease-in hover:bg-discord-blurple hover:text-white hover:shadow-lg'
} >
</>
</a>
} )}
</>
)}
</div> </div>
</div> </div>
</div> </div>
@ -146,28 +161,26 @@ const ServerCard: React.FC<BotCardProps> = ({ type, server }) => {
</div> </div>
</div> </div>
) )
} }
interface BotCardProps { interface BotCardProps {
type: 'list' | 'manage' | 'add' type: 'list' | 'manage' | 'add'
server: { server: {
id: string, id: string
name: string, name: string
intro?: string intro?: string
desc?: string, desc?: string
flags?: number flags?: number
state?: ServerState state?: ServerState
icon: string | null, icon: string | null
banner?: string | null, banner?: string | null
bg?: string | null, bg?: string | null
vanity?: string | null vanity?: string | null
category?: string[] category?: string[]
votes?: number | null votes?: number | null
members?: number | null, members?: number | null
data?: ServerData data?: ServerData
} }
} }
export default ServerCard export default ServerCard

View File

@ -5,13 +5,22 @@ import { DiscordEnpoints, KoreanbotsEndPoints } from '@utils/Constants'
const Image = dynamic(() => import('@components/Image')) const Image = dynamic(() => import('@components/Image'))
const ServerIcon: React.FC<ServerIconProps> = ({ id, size, className, alt, hash }) => { const ServerIcon: React.FC<ServerIconProps> = ({ id, size, className, alt, hash }) => {
return <Image return (
className={className} <Image
alt={alt} className={className}
src={hash ? DiscordEnpoints.CDN.guild(id, hash, { format: 'webp', size: size ?? 256 }) : KoreanbotsEndPoints.CDN.icon(id, { format: 'webp', size: size ?? 256})} alt={alt}
fallbackSrc={hash ? DiscordEnpoints.CDN.guild(id, hash, { format: 'png', size: size ?? 256 }) : KoreanbotsEndPoints.CDN.icon(id, { format: 'png', size: size ?? 256})} 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 { interface ServerIconProps {
@ -20,7 +29,7 @@ interface ServerIconProps {
hash?: string hash?: string
fromDiscord?: boolean fromDiscord?: boolean
className?: string className?: string
size? : 128 | 256 | 512 size?: 128 | 256 | 512
} }
interface ImageEvent extends Event { interface ImageEvent extends Event {

View File

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

View File

@ -26,18 +26,18 @@ const Tag: React.FC<LabelProps> = ({
? 'bg-discord-blurple text-white' ? 'bg-discord-blurple text-white'
: 'bg-little-white-hover hover:bg-little-white dark:bg-very-black' : 'bg-little-white-hover hover:bg-little-white dark:bg-very-black'
: github : github
? 'bg-gray-900 text-white hover:bg-gray-700' ? 'bg-gray-900 text-white hover:bg-gray-700'
: 'bg-little-white dark:bg-discord-black hover:bg-little-white-hover' : 'bg-little-white hover:bg-little-white-hover dark:bg-discord-black'
} ${!blurple && !github ? 'text-black dark:text-gray-400' : ''} ${ } ${!blurple && !github ? 'text-black dark:text-gray-400' : ''} ${
circular circular
? `rounded-3xl ${bigger ? 'px-3.5 py-2.5' : 'px-2.5 py-1.5'}` ? `rounded-3xl ${bigger ? 'px-3.5 py-2.5' : 'px-2.5 py-1.5'}`
: `rounded ${bigger ? 'px-3 py-2' : 'px-2 py-1'}` : `rounded ${bigger ? 'px-3 py-2' : 'px-2 py-1'}`
} mr-1 mb-${marginBottom} dark:hover:bg-discord-dark-hover transition duration-100 ease-in`} } mr-1 mb-${marginBottom} transition duration-100 ease-in dark:hover:bg-discord-dark-hover`}
> >
{text} {text}
</a> </a>
) : ( ) : (
(<Link <Link
href={href} href={href}
className={`${className ?? ''} text-center text-base ${ className={`${className ?? ''} text-center text-base ${
dark dark
@ -45,19 +45,18 @@ const Tag: React.FC<LabelProps> = ({
? 'bg-discord-blurple text-white' ? 'bg-discord-blurple text-white'
: 'bg-little-white-hover hover:bg-little-white dark:bg-very-black' : 'bg-little-white-hover hover:bg-little-white dark:bg-very-black'
: github : github
? 'bg-gray-900 text-white hover:bg-gray-700' ? 'bg-gray-900 text-white hover:bg-gray-700'
: 'bg-little-white dark:bg-discord-black hover:bg-little-white-hover' : 'bg-little-white hover:bg-little-white-hover dark:bg-discord-black'
} ${ } ${
!blurple && !github ? 'text-black dark:text-gray-400' : 'hover:bg-little-white-hover' !blurple && !github ? 'text-black dark:text-gray-400' : 'hover:bg-little-white-hover'
} ${ } ${
circular circular
? `rounded-3xl ${bigger ? 'px-3.5 py-2.5' : 'px-2.5 py-1.5'}` ? `rounded-3xl ${bigger ? 'px-3.5 py-2.5' : 'px-2.5 py-1.5'}`
: `rounded ${bigger ? 'px-3 py-2' : 'px-2 py-1'}` : `rounded ${bigger ? 'px-3 py-2' : 'px-2 py-1'}`
} mr-1 mb-${marginBottom} dark:hover:bg-discord-dark-hover transition duration-100 ease-in`}> } mr-1 mb-${marginBottom} transition duration-100 ease-in dark:hover:bg-discord-dark-hover`}
>
{text} {text}
</Link>
</Link>)
) )
) : ( ) : (
<a <a
@ -67,17 +66,17 @@ const Tag: React.FC<LabelProps> = ({
? blurple ? blurple
? 'font-bg bg-discord-blurple text-white' ? 'font-bg bg-discord-blurple text-white'
: github : github
? 'bg-gray-900 text-white hover:bg-gray-700' ? 'bg-gray-900 text-white hover:bg-gray-700'
: `bg-little-white-hover dark:bg-very-black ${ : `bg-little-white-hover dark:bg-very-black ${
props.onClick props.onClick
? 'hover:bg-little-white dark:hover:bg-discord-dark-hover transition duration-100 ease-in' ? 'transition duration-100 ease-in hover:bg-little-white dark:hover:bg-discord-dark-hover'
: '' : ''
}` }`
: `bg-little-white dark:bg-discord-black ${ : `bg-little-white dark:bg-discord-black ${
props.onClick props.onClick
? 'hover:bg-little-white-hover dark:hover:bg-discord-dark-hover transition duration-100 ease-in' ? 'transition duration-100 ease-in hover:bg-little-white-hover dark:hover:bg-discord-dark-hover'
: '' : ''
}` }`
} ${!blurple && !github ? 'text-black dark:text-gray-400' : ''} ${ } ${!blurple && !github ? 'text-black dark:text-gray-400' : ''} ${
circular circular
? `rounded-3xl ${bigger ? 'px-3.5 py-2.5' : 'px-2.5 py-1.5'}` ? `rounded-3xl ${bigger ? 'px-3.5 py-2.5' : 'px-2.5 py-1.5'}`

View File

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

View File

@ -8,15 +8,14 @@ const Tooltip: React.FC<TooltipProps> = ({
text, text,
}) => { }) => {
return href ? ( return href ? (
(<Link href={href} className='inline'> <Link href={href} className='inline'>
<div className='relative inline py-3'> <div className='relative inline py-3'>
<div className='group relative inline-block text-center cursor-pointer'> <div className='group relative inline-block cursor-pointer text-center'>
{children} {children}
<div <div
className={`opacity-0 ${ className={`opacity-0 ${
size === 'small' ? 'w-44' : 'w-60' size === 'small' ? 'w-44' : 'w-60'
} bg-black text-white text-center text-xs rounded-lg py-2 px-3 absolute z-10 group-hover:opacity-100 bottom-full -left-4 pointer-events-none`} } pointer-events-none absolute -left-4 bottom-full z-10 rounded-lg bg-black px-3 py-2 text-center text-xs text-white group-hover:opacity-100`}
> >
{text} {text}
{direction === 'left' ? ( {direction === 'left' ? (
@ -31,7 +30,7 @@ const Tooltip: React.FC<TooltipProps> = ({
</svg> </svg>
) : direction === 'center' ? ( ) : direction === 'center' ? (
<svg <svg
className='absolute left-0 top-full w-full h-2 text-black' className='absolute left-0 top-full h-2 w-full text-black'
x='0px' x='0px'
y='0px' y='0px'
viewBox='0 0 255 255' viewBox='0 0 255 255'
@ -53,17 +52,16 @@ const Tooltip: React.FC<TooltipProps> = ({
</div> </div>
</div> </div>
</div> </div>
</Link>
</Link>)
) : ( ) : (
<a className='inline'> <a className='inline'>
<div className='relative inline py-3'> <div className='relative inline py-3'>
<div className='group relative inline-block text-center cursor-pointer'> <div className='group relative inline-block cursor-pointer text-center'>
{children} {children}
<div <div
className={`opacity-0 ${ className={`opacity-0 ${
size === 'small' ? 'w-44' : 'w-60' size === 'small' ? 'w-44' : 'w-60'
} bg-black text-white text-center text-xs rounded-lg py-2 px-3 absolute z-10 group-hover:opacity-100 bottom-full -left-4 pointer-events-none`} } pointer-events-none absolute -left-4 bottom-full z-10 rounded-lg bg-black px-3 py-2 text-center text-xs text-white group-hover:opacity-100`}
> >
{text} {text}
{direction === 'left' ? ( {direction === 'left' ? (
@ -78,7 +76,7 @@ const Tooltip: React.FC<TooltipProps> = ({
</svg> </svg>
) : direction === 'center' ? ( ) : direction === 'center' ? (
<svg <svg
className='absolute left-0 top-full w-full h-2 text-black' className='absolute left-0 top-full h-2 w-full text-black'
x='0px' x='0px'
y='0px' y='0px'
viewBox='0 0 255 255' viewBox='0 0 255 255'

View File

@ -3,20 +3,19 @@ import { ErrorText } from '@utils/Constants'
const NotFound: NextPage<{ message?: string }> = ({ message }) => { const NotFound: NextPage<{ message?: string }> = ({ message }) => {
return ( return (
<div <div className='flex h-screen select-none items-center justify-center text-center'>
className='flex items-center justify-center h-screen select-none text-center'
>
<div> <div>
<div className='flex flex-row justify-center text-9xl'> <div className='flex flex-row justify-center text-9xl'>
4 4
<img alt='robot' src='https://cdnjs.cloudflare.com/ajax/libs/twemoji/14.0.2/svg/1f916.svg' className='w-24 mx-6 md:mx-12 rounded-full' /> <img
alt='robot'
src='https://cdnjs.cloudflare.com/ajax/libs/twemoji/14.0.2/svg/1f916.svg'
className='mx-6 w-24 rounded-full md:mx-12'
/>
4 4
</div> </div>
<h2 className='text-2xl font-semibold'> <h2 className='text-2xl font-semibold'>{message || ErrorText[404]}</h2>
{message || ErrorText[404]}
</h2>
</div> </div>
</div> </div>
) )
} }

View File

@ -34,9 +34,9 @@ Router.events.on('routeChangeError', NProgress.done)
ReactGA.initialize('UA-165454387-1') ReactGA.initialize('UA-165454387-1')
const KoreanbotsApp = ({ Component, pageProps, err, cookie }: KoreanbotsProps): JSX.Element => { const KoreanbotsApp = ({ Component, pageProps, err, cookie }: KoreanbotsProps): JSX.Element => {
const [ shortcutModal, setShortcutModal ] = useState(false) const [shortcutModal, setShortcutModal] = useState(false)
const [ theme, setTheme ] = useState<Theme>('system') const [theme, setTheme] = useState<Theme>('system')
const [ standalone, setStandalone ] = useState(false) const [standalone, setStandalone] = useState(false)
const router = useRouter() const router = useRouter()
useEffect(() => { useEffect(() => {
@ -44,7 +44,11 @@ const KoreanbotsApp = ({ Component, pageProps, err, cookie }: KoreanbotsProps):
'%c' + 'KOREANBOTS', '%c' + 'KOREANBOTS',
'color: #3366FF; -webkit-text-stroke: 2px black; font-size: 72px; font-weight: bold;' 'color: #3366FF; -webkit-text-stroke: 2px black; font-size: 72px; font-weight: bold;'
) )
Logger.debug(`[BUILD INFO] Tag: ${parseDockerhubTag(process.env.NEXT_PUBLIC_TAG)}, Version: v${Package.version}, Hash: ${process.env.NEXT_PUBLIC_SOURCE_COMMIT}`) Logger.debug(
`[BUILD INFO] Tag: ${parseDockerhubTag(process.env.NEXT_PUBLIC_TAG)}, Version: v${
Package.version
}, Hash: ${process.env.NEXT_PUBLIC_SOURCE_COMMIT}`
)
console.log( console.log(
'%c' + '이곳에 코드를 붙여넣으면 공격자에게 엑세스 토큰을 넘겨줄 수 있습니다!!', '%c' + '이곳에 코드를 붙여넣으면 공격자에게 엑세스 토큰을 넘겨줄 수 있습니다!!',
'color: #ff0000; font-size: 20px; font-weight: bold;' 'color: #ff0000; font-size: 20px; font-weight: bold;'
@ -52,127 +56,147 @@ const KoreanbotsApp = ({ Component, pageProps, err, cookie }: KoreanbotsProps):
if (!localStorage.theme) { if (!localStorage.theme) {
Logger.debug(`[THEME] ${systemTheme().toUpperCase()} THEME DETECTED`) Logger.debug(`[THEME] ${systemTheme().toUpperCase()} THEME DETECTED`)
setTheme(systemTheme()) setTheme(systemTheme())
} } else setTheme(localStorage.theme)
else setTheme(localStorage.theme)
setStandalone(handlePWA()) setStandalone(handlePWA())
const script = document.querySelector('script[src*=googlesyndication]') const script = document.querySelector('script[src*=googlesyndication]')
if (script) script.addEventListener('error', () => {ReactGA.ga('send', 'event', 'adblock', 'adblock_' + (navigator.userAgent.match(/Android|BlackBerry|iPhone|iPad|iPod|Opera Mini|IEMobile|WPDesktop/i) ? 'mobile' : 'pc'))}) if (script)
script.addEventListener('error', () => {
ReactGA.ga(
'send',
'event',
'adblock',
'adblock_' +
(navigator.userAgent.match(
/Android|BlackBerry|iPhone|iPad|iPod|Opera Mini|IEMobile|WPDesktop/i
)
? 'mobile'
: 'pc')
)
})
}, []) }, [])
return <div className={theme}> return (
<DefaultSeo <div className={theme}>
titleTemplate='%s - 한국 디스코드 리스트' <DefaultSeo
defaultTitle={TITLE} titleTemplate='%s - 한국 디스코드 리스트'
description={DESCRIPTION} defaultTitle={TITLE}
openGraph={{ description={DESCRIPTION}
type: 'website', openGraph={{
title: TITLE, type: 'website',
url: 'https://koreanbots.dev', title: TITLE,
site_name: TITLE, url: 'https://koreanbots.dev',
description: DESCRIPTION, site_name: TITLE,
images: [ description: DESCRIPTION,
{ images: [
url: '/logo.png', {
width: 300, url: '/logo.png',
height: 300, width: 300,
alt: 'Logo' height: 300,
} alt: 'Logo',
] },
}} ],
twitter={{ }}
site: '@koreanbots', twitter={{
handle: '@koreanbots', site: '@koreanbots',
cardType: 'summary' handle: '@koreanbots',
}} cardType: 'summary',
/> }}
<Head> />
{/* META */} <Head>
<meta charSet='utf-8' /> {/* META */}
<meta httpEquiv='X-UA-Compatible' content='IE=edge' /> <meta charSet='utf-8' />
<meta name='keywords' content='Korea, Korean, Discord, Bot, 디스코드봇, 한디리' /> <meta httpEquiv='X-UA-Compatible' content='IE=edge' />
<meta name='viewport' content='width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1,user-scalable=no' /> <meta name='keywords' content='Korea, Korean, Discord, Bot, 디스코드봇, 한디리' />
<meta
name='viewport'
content='width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1,user-scalable=no'
/>
{/* Android */} {/* Android */}
<meta name='theme-color' content={THEME_COLOR} /> <meta name='theme-color' content={THEME_COLOR} />
<meta name='mobile-web-app-capable' content='yes' /> <meta name='mobile-web-app-capable' content='yes' />
{/* iOS */}
<meta name='apple-mobile-web-app-title' content='Application Title' />
<meta name='apple-mobile-web-app-capable' content='yes' />
<meta name='apple-mobile-web-app-status-bar-style' content='default' />
{/* Windows */}
<meta name='msapplication-navbutton-color' content={THEME_COLOR} />
<meta name='msapplication-TileColor' content={THEME_COLOR} />
<meta name='msapplication-TileImage' content='/static/ms-icon-144x144.png' />
<meta name='msapplication-config' content='browserconfig.xml' />
{/* Pinned Sites */} {/* iOS */}
<meta name='application-name' content={TITLE} /> <meta name='apple-mobile-web-app-title' content='Application Title' />
<meta name='msapplication-tooltip' content={DESCRIPTION} /> <meta name='apple-mobile-web-app-capable' content='yes' />
<meta name='msapplication-starturl' content='/' /> <meta name='apple-mobile-web-app-status-bar-style' content='default' />
{/* Tap highlighting */} {/* Windows */}
<meta name='msapplication-tap-highlight' content='no' /> <meta name='msapplication-navbutton-color' content={THEME_COLOR} />
<meta name='msapplication-TileColor' content={THEME_COLOR} />
<meta name='msapplication-TileImage' content='/static/ms-icon-144x144.png' />
<meta name='msapplication-config' content='browserconfig.xml' />
{/* UC Mobile Browser */} {/* Pinned Sites */}
<meta name='full-screen' content='yes' /> <meta name='application-name' content={TITLE} />
<meta name='browsermode' content='application' /> <meta name='msapplication-tooltip' content={DESCRIPTION} />
<meta name='msapplication-starturl' content='/' />
<meta name='nightmode' content='disable' /> {/* Tap highlighting */}
<meta name='layoutmode' content='fitscreen' /> <meta name='msapplication-tap-highlight' content='no' />
<meta name='imagemode' content='force' />
<meta name='screen-orientation' content='portrait' /> {/* UC Mobile Browser */}
<meta name='full-screen' content='yes' />
</Head> <meta name='browsermode' content='application' />
<Navbar token={cookie.token} />
<div className='iu-is-the-best min-h-screen text-black dark:text-gray-100 dark:bg-discord-dark bg-white'> <meta name='nightmode' content='disable' />
<Component {...pageProps} err={err} theme={theme} setTheme={setTheme} pwa={standalone} /> <meta name='layoutmode' content='fitscreen' />
</div> <meta name='imagemode' content='force' />
{ <meta name='screen-orientation' content='portrait' />
!(router.pathname.startsWith('/developers')) && <Footer theme={theme} setTheme={setTheme} /> </Head>
} <Navbar token={cookie.token} />
<Modal full isOpen={shortcutModal} onClose={() => setShortcutModal(false)} dark={theme === 'dark'} header='단축키 안내'> <div className='iu-is-the-best min-h-screen bg-white text-black dark:bg-discord-dark dark:text-gray-100'>
<div className='px-3 h-80'> <Component {...pageProps} err={err} theme={theme} setTheme={setTheme} pwa={standalone} />
<h3 className='text-md font-semibold'></h3>
<ul>
<li className='pt-2'>
<h4 className='text-gray-500 dark:text-gray-400 text-xs'> </h4>
<kbd>
<PlatformDisplay osx='CMD'>
Ctrl
</PlatformDisplay>
</kbd> <kbd>/</kbd>
</li>
<li className='pt-2'>
<h4 className='text-gray-500 dark:text-gray-400 text-xs'> </h4>
<kbd>
<PlatformDisplay osx='CMD'>
Ctrl
</PlatformDisplay>
</kbd>
<kbd>Shift</kbd> <kbd>D</kbd>
</li>
</ul>
</div> </div>
</Modal> {!router.pathname.startsWith('/developers') && <Footer theme={theme} setTheme={setTheme} />}
<GlobalHotKeys keyMap={shortcutKeyMap} handlers={{ <Modal
SHORTCUT_HELP: (event) => { full
event.preventDefault() isOpen={shortcutModal}
setShortcutModal(value => !value) onClose={() => setShortcutModal(false)}
return dark={theme === 'dark'}
}, header='단축키 안내'
CHANGE_THEME: (event) => { >
event.preventDefault() <div className='h-80 px-3'>
const overwrite = (localStorage.theme || systemTheme()) === 'dark' ? 'light' : 'dark' <h3 className='text-md font-semibold'></h3>
setTheme(overwrite) <ul>
localStorage.setItem('theme', overwrite) <li className='pt-2'>
return false <h4 className='text-xs text-gray-500 dark:text-gray-400'> </h4>
} <kbd>
}} /> <PlatformDisplay osx='CMD'>Ctrl</PlatformDisplay>
</div> </kbd>{' '}
<kbd>/</kbd>
</li>
<li className='pt-2'>
<h4 className='text-xs text-gray-500 dark:text-gray-400'> </h4>
<kbd>
<PlatformDisplay osx='CMD'>Ctrl</PlatformDisplay>
</kbd>
<kbd>Shift</kbd> <kbd>D</kbd>
</li>
</ul>
</div>
</Modal>
<GlobalHotKeys
keyMap={shortcutKeyMap}
handlers={{
SHORTCUT_HELP: (event) => {
event.preventDefault()
setShortcutModal((value) => !value)
return
},
CHANGE_THEME: (event) => {
event.preventDefault()
const overwrite = (localStorage.theme || systemTheme()) === 'dark' ? 'light' : 'dark'
setTheme(overwrite)
localStorage.setItem('theme', overwrite)
return false
},
}}
/>
</div>
)
} }
KoreanbotsApp.getInitialProps = async (appCtx: AppContext) => { KoreanbotsApp.getInitialProps = async (appCtx: AppContext) => {
@ -180,7 +204,7 @@ KoreanbotsApp.getInitialProps = async (appCtx: AppContext) => {
const parsed = parseCookie(appCtx.ctx.req) const parsed = parseCookie(appCtx.ctx.req)
return { return {
...appProps, ...appProps,
cookie: parsed cookie: parsed,
} }
} }

View File

@ -13,7 +13,12 @@ class MyDocument extends Document {
<Head> <Head>
{/* LINK */} {/* LINK */}
<link rel='manifest' href='/manifest.json' /> <link rel='manifest' href='/manifest.json' />
<link rel='search' type='application/opensearchdescription+xml' title={TITLE} href='/opensearch.xml' /> <link
rel='search'
type='application/opensearchdescription+xml'
title={TITLE}
href='/opensearch.xml'
/>
<link <link
rel='stylesheet' rel='stylesheet'
href='//cdnjs.cloudflare.com/ajax/libs/highlight.js/10.6.0/styles/solarized-dark.min.css' href='//cdnjs.cloudflare.com/ajax/libs/highlight.js/10.6.0/styles/solarized-dark.min.css'
@ -21,8 +26,13 @@ class MyDocument extends Document {
<link rel='icon' type='image/png' sizes='32x32' href='/favicon-32x32.png' /> <link rel='icon' type='image/png' sizes='32x32' href='/favicon-32x32.png' />
<link rel='icon' type='image/png' sizes='96x96' href='/favicon-96x96.png' /> <link rel='icon' type='image/png' sizes='96x96' href='/favicon-96x96.png' />
<link rel='icon' type='image/png' sizes='16x16' href='/favicon-16x16.png' /> <link rel='icon' type='image/png' sizes='16x16' href='/favicon-16x16.png' />
<link rel='stylesheet' as='style' crossOrigin='anonymous' href='https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.6/dist/web/variable/pretendardvariable-dynamic-subset.css' /> <link
rel='stylesheet'
as='style'
crossOrigin='anonymous'
href='https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.6/dist/web/variable/pretendardvariable-dynamic-subset.css'
/>
{/* iOS */} {/* iOS */}
<link rel='apple-touch-icon' sizes='57x57' href='/static/apple-icon-57x57.png' /> <link rel='apple-touch-icon' sizes='57x57' href='/static/apple-icon-57x57.png' />
<link rel='apple-touch-icon' sizes='60x60' href='/static/apple-icon-60x60.png' /> <link rel='apple-touch-icon' sizes='60x60' href='/static/apple-icon-60x60.png' />
@ -35,43 +45,160 @@ class MyDocument extends Document {
<link rel='apple-touch-icon' sizes='180x180' href='/static/apple-icon-180x180.png' /> <link rel='apple-touch-icon' sizes='180x180' href='/static/apple-icon-180x180.png' />
<link rel='apple-touch-icon' sizes='256x256' href='/static/apple-icon-256x256.png' /> <link rel='apple-touch-icon' sizes='256x256' href='/static/apple-icon-256x256.png' />
<link rel='apple-touch-icon' sizes='512x512' href='/static/apple-icon-512x512.png' /> <link rel='apple-touch-icon' sizes='512x512' href='/static/apple-icon-512x512.png' />
<link rel='apple-touch-startup-image' href='/static/apple-splash-2048-2732.jpg' media='(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)' /> <link
<link rel='apple-touch-startup-image' href='/static/apple-splash-2732-2048.jpg' media='(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)' /> rel='apple-touch-startup-image'
<link rel='apple-touch-startup-image' href='/static/apple-splash-1668-2388.jpg' media='(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)' /> href='/static/apple-splash-2048-2732.jpg'
<link rel='apple-touch-startup-image' href='/static/apple-splash-2388-1668.jpg' media='(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)' /> media='(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)'
<link rel='apple-touch-startup-image' href='/static/apple-splash-1536-2048.jpg' media='(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)' /> />
<link rel='apple-touch-startup-image' href='/static/apple-splash-2048-1536.jpg' media='(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)' /> <link
<link rel='apple-touch-startup-image' href='/static/apple-splash-1668-2224.jpg' media='(device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)' /> rel='apple-touch-startup-image'
<link rel='apple-touch-startup-image' href='/static/apple-splash-2224-1668.jpg' media='(device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)' /> href='/static/apple-splash-2732-2048.jpg'
<link rel='apple-touch-startup-image' href='/static/apple-splash-1620-2160.jpg' media='(device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)' /> media='(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)'
<link rel='apple-touch-startup-image' href='/static/apple-splash-2160-1620.jpg' media='(device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)' /> />
<link rel='apple-touch-startup-image' href='/static/apple-splash-1284-2778.jpg' media='(device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)' /> <link
<link rel='apple-touch-startup-image' href='/static/apple-splash-2778-1284.jpg' media='(device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)' /> rel='apple-touch-startup-image'
<link rel='apple-touch-startup-image' href='/static/apple-splash-1170-2532.jpg' media='(device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)' /> href='/static/apple-splash-1668-2388.jpg'
<link rel='apple-touch-startup-image' href='/static/apple-splash-2532-1170.jpg' media='(device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)' /> media='(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)'
<link rel='apple-touch-startup-image' href='/static/apple-splash-1125-2436.jpg' media='(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)' /> />
<link rel='apple-touch-startup-image' href='/static/apple-splash-2436-1125.jpg' media='(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)' /> <link
<link rel='apple-touch-startup-image' href='/static/apple-splash-1242-2688.jpg' media='(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)' /> rel='apple-touch-startup-image'
<link rel='apple-touch-startup-image' href='/static/apple-splash-2688-1242.jpg' media='(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)' /> href='/static/apple-splash-2388-1668.jpg'
<link rel='apple-touch-startup-image' href='/static/apple-splash-828-1792.jpg' media='(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)' /> media='(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)'
<link rel='apple-touch-startup-image' href='/static/apple-splash-1792-828.jpg' media='(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)' /> />
<link rel='apple-touch-startup-image' href='/static/apple-splash-1242-2208.jpg' media='(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)' /> <link
<link rel='apple-touch-startup-image' href='/static/apple-splash-2208-1242.jpg' media='(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)' /> rel='apple-touch-startup-image'
<link rel='apple-touch-startup-image' href='/static/apple-splash-750-1334.jpg' media='(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)' /> href='/static/apple-splash-1536-2048.jpg'
<link rel='apple-touch-startup-image' href='/static/apple-splash-1334-750.jpg' media='(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)' /> media='(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)'
<link rel='apple-touch-startup-image' href='/static/apple-splash-640-1136.jpg' media='(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)' /> />
<link rel='apple-touch-startup-image' href='/static/apple-splash-1136-640.jpg' media='(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)' /> <link
rel='apple-touch-startup-image'
href='/static/apple-splash-2048-1536.jpg'
media='(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)'
/>
<link
rel='apple-touch-startup-image'
href='/static/apple-splash-1668-2224.jpg'
media='(device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)'
/>
<link
rel='apple-touch-startup-image'
href='/static/apple-splash-2224-1668.jpg'
media='(device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)'
/>
<link
rel='apple-touch-startup-image'
href='/static/apple-splash-1620-2160.jpg'
media='(device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)'
/>
<link
rel='apple-touch-startup-image'
href='/static/apple-splash-2160-1620.jpg'
media='(device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)'
/>
<link
rel='apple-touch-startup-image'
href='/static/apple-splash-1284-2778.jpg'
media='(device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)'
/>
<link
rel='apple-touch-startup-image'
href='/static/apple-splash-2778-1284.jpg'
media='(device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)'
/>
<link
rel='apple-touch-startup-image'
href='/static/apple-splash-1170-2532.jpg'
media='(device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)'
/>
<link
rel='apple-touch-startup-image'
href='/static/apple-splash-2532-1170.jpg'
media='(device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)'
/>
<link
rel='apple-touch-startup-image'
href='/static/apple-splash-1125-2436.jpg'
media='(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)'
/>
<link
rel='apple-touch-startup-image'
href='/static/apple-splash-2436-1125.jpg'
media='(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)'
/>
<link
rel='apple-touch-startup-image'
href='/static/apple-splash-1242-2688.jpg'
media='(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)'
/>
<link
rel='apple-touch-startup-image'
href='/static/apple-splash-2688-1242.jpg'
media='(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)'
/>
<link
rel='apple-touch-startup-image'
href='/static/apple-splash-828-1792.jpg'
media='(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)'
/>
<link
rel='apple-touch-startup-image'
href='/static/apple-splash-1792-828.jpg'
media='(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)'
/>
<link
rel='apple-touch-startup-image'
href='/static/apple-splash-1242-2208.jpg'
media='(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)'
/>
<link
rel='apple-touch-startup-image'
href='/static/apple-splash-2208-1242.jpg'
media='(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)'
/>
<link
rel='apple-touch-startup-image'
href='/static/apple-splash-750-1334.jpg'
media='(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)'
/>
<link
rel='apple-touch-startup-image'
href='/static/apple-splash-1334-750.jpg'
media='(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)'
/>
<link
rel='apple-touch-startup-image'
href='/static/apple-splash-640-1136.jpg'
media='(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)'
/>
<link
rel='apple-touch-startup-image'
href='/static/apple-splash-1136-640.jpg'
media='(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)'
/>
{/* Android */} {/* Android */}
<link rel='icon' type='image/png' sizes='192x192' href='/static/android-icon-192x192.png' /> <link
rel='icon'
type='image/png'
sizes='192x192'
href='/static/android-icon-192x192.png'
/>
{/* Others */} {/* Others */}
<link rel='shortcut icon' href='/favicon.ico' /> <link rel='shortcut icon' href='/favicon.ico' />
{/* SCRIPT */} {/* SCRIPT */}
<script src='//cdnjs.cloudflare.com/ajax/libs/highlight.js/10.5.0/highlight.min.js'></script> <script src='//cdnjs.cloudflare.com/ajax/libs/highlight.js/10.5.0/highlight.min.js'></script>
<script data-ad-client='ca-pub-4856582423981759' async src='//pagead2.googlesyndication.com/pagead/js/adsbygoogle.js'></script> <script
<script data-cfasync='false' async src='//www.googletagmanager.com/gtag/js?id=UA-165454387-1'></script> data-ad-client='ca-pub-4856582423981759'
async
src='//pagead2.googlesyndication.com/pagead/js/adsbygoogle.js'
></script>
<script
data-cfasync='false'
async
src='//www.googletagmanager.com/gtag/js?id=UA-165454387-1'
></script>
<script <script
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: ` __html: `
@ -91,7 +218,7 @@ class MyDocument extends Document {
}} }}
/> />
</Head> </Head>
<body className='h-full text-black dark:text-gray-100 dark:bg-discord-dark bg-white overflow-x-hidden'> <body className='h-full overflow-x-hidden bg-white text-black dark:bg-discord-dark dark:text-gray-100'>
<Main /> <Main />
<NextScript /> <NextScript />
</body> </body>

View File

@ -8,24 +8,35 @@ const Container = dynamic(() => import('@components/Container'))
const MyError: NextPage = () => { const MyError: NextPage = () => {
return ( return (
<div <div className='flex h-screen select-none items-center px-20'>
className='flex items-center h-screen select-none px-20'
>
<Container> <Container>
<h2 className='text-4xl font-semibold'>{getRandom(ErrorMessage)}</h2> <h2 className='text-4xl font-semibold'>{getRandom(ErrorMessage)}</h2>
<p className='text-md mt-1'> . !</p> <p className='text-md mt-1'>
<a className='text-sm text-blue-500 hover:text-blue-400' href='https://status.koreanbots.dev' target='_blank' rel='noreferrer'> </a> . !
</p>
<a
className='text-sm text-blue-500 hover:text-blue-400'
href='https://status.koreanbots.dev'
target='_blank'
rel='noreferrer'
>
</a>
<div> <div>
<Link <Link
href='/discord' href='/discord'
target='_blank' target='_blank'
rel='noreferrer' rel='noreferrer'
className='text-lg hover:opacity-80 cursor-pointer'> className='cursor-pointer text-lg hover:opacity-80'
>
<i className='fab fa-discord' /> <i className='fab fa-discord' />
</Link> </Link>
<a href='https://twitter.com/koreanbots' target='_blank' rel='noreferrer' className='text-lg ml-2 hover:opacity-80 cursor-pointer'> <a
href='https://twitter.com/koreanbots'
target='_blank'
rel='noreferrer'
className='ml-2 cursor-pointer text-lg hover:opacity-80'
>
<i className='fab fa-twitter' /> <i className='fab fa-twitter' />
</a> </a>
</div> </div>
@ -34,4 +45,4 @@ const MyError: NextPage = () => {
) )
} }
export default MyError export default MyError

View File

@ -6,24 +6,33 @@ const Container = dynamic(() => import('@components/Container'))
const MyError: NextPage = () => { const MyError: NextPage = () => {
return ( return (
<div <div className='flex h-screen select-none items-center px-20'>
className='flex items-center h-screen select-none px-20'
>
<Container> <Container>
<h2 className='text-4xl font-semibold'> ...</h2> <h2 className='text-4xl font-semibold'> ...</h2>
<p className='text-md mt-1'> !</p> <p className='text-md mt-1'> !</p>
<a className='text-sm text-blue-500 hover:text-blue-400' href='https://status.koreanbots.dev' target='_blank' rel='noreferrer'> </a> <a
className='text-sm text-blue-500 hover:text-blue-400'
href='https://status.koreanbots.dev'
target='_blank'
rel='noreferrer'
>
</a>
<div> <div>
<Link <Link
href='/discord' href='/discord'
target='_blank' target='_blank'
rel='noreferrer' rel='noreferrer'
className='text-lg hover:opacity-80 cursor-pointer'> className='cursor-pointer text-lg hover:opacity-80'
>
<i className='fab fa-discord' /> <i className='fab fa-discord' />
</Link> </Link>
<a href='https://twitter.com/koreanbots' target='_blank' rel='noreferrer' className='text-lg ml-2 hover:opacity-80 cursor-pointer'> <a
href='https://twitter.com/koreanbots'
target='_blank'
rel='noreferrer'
className='ml-2 cursor-pointer text-lg hover:opacity-80'
>
<i className='fab fa-twitter' /> <i className='fab fa-twitter' />
</a> </a>
</div> </div>
@ -32,4 +41,4 @@ const MyError: NextPage = () => {
) )
} }
export default MyError export default MyError

View File

@ -9,70 +9,102 @@ import { ThemeColors } from '@utils/Constants'
const Container = dynamic(() => import('@components/Container')) const Container = dynamic(() => import('@components/Container'))
const About:NextPage = () => { const About: NextPage = () => {
return <div className='pb-10'> return (
<Docs title='소개' header={<h1 className='font-black text-4xl dark:text-koreanbots-blue'> .</h1>} subheader='한국 디스코드 리스트에서 자신에게 필요한 디스코드의 모든 것을 찾아보세요!'> <div className='pb-10'>
<Container> <Docs
<div className='py-1'> title='소개'
<h1 className='font-bold text-5xl my-5'></h1> header={
<p className='text-lg'><span className='text-koreanbots-blue font-bold'> </span> , .</p> <h1 className='text-4xl font-black dark:text-koreanbots-blue'>
<p className='text-lg'> !</p> .
<Divider /> </h1>
<h1 className='font-bold text-5xl my-5'></h1> }
<div className='grid md:grid-cols-3 gap-12 px-4 pb-5'> subheader='한국 디스코드 리스트에서 자신에게 필요한 디스코드의 모든 것을 찾아보세요!'
<div className='mx-auto font-normal'> >
<h2 className='text-3xl mb-1 font-bold text-koreanbots-blue'> </h2> <Container>
<p className='text-base'> .</p> <div className='py-1'>
<h1 className='my-5 text-5xl font-bold'></h1>
<p className='text-lg'>
<span className='font-bold text-koreanbots-blue'> </span>
,
.
</p>
<p className='text-lg'> !</p>
<Divider />
<h1 className='my-5 text-5xl font-bold'></h1>
<div className='grid gap-12 px-4 pb-5 md:grid-cols-3'>
<div className='mx-auto font-normal'>
<h2 className='mb-1 text-3xl font-bold text-koreanbots-blue'> </h2>
<p className='text-base'>
.
</p>
</div>
<div className='mx-auto font-normal'>
<h2 className='mb-1 text-3xl font-bold text-koreanbots-blue'> </h2>
<p className='text-base'>
, .
</p>
</div>
<div className='mx-auto font-normal'>
<h2 className='mb-1 text-3xl font-bold text-koreanbots-blue'>API </h2>
<p className='text-base'>
, , .
<br />
API를 !
</p>
</div>
</div> </div>
<div className='mx-auto font-normal'> <Divider />
<h2 className='text-3xl mb-1 font-bold text-koreanbots-blue'> </h2> <h1 className='my-5 text-5xl font-bold'></h1>
<p className='text-base'> , .</p> <h2 className='mb-7 text-3xl font-semibold'></h2>
</div> <Segment>
<div className='mx-auto font-normal'> <h2 className='py-10 text-center text-xl font-semibold'>
<h2 className='text-3xl mb-1 font-bold text-koreanbots-blue'>API </h2> <i className='fas fa-quote-left align-top text-xs' />
<p className='text-base'>, , .<br /> API를 !</p> .
</div> <i className='fas fa-quote-right align-bottom text-xs' />
</div> </h2>
<Divider /> </Segment>
<h1 className='font-bold text-5xl my-5'></h1> <Divider className='mt-7' />
<h2 className='font-semibold text-3xl mb-7'></h2> <h2 className='my-7 text-3xl font-semibold'></h2>
<Segment> <Segment>
<h2 className='font-semibold text-xl py-10 text-center'> <>
<i className='fas fa-quote-left text-xs align-top' /> , , .
. <div className='grid md:grid-cols-2 lg:grid-cols-4'>
<i className='fas fa-quote-right text-xs align-bottom' /> <div>
</h2> <img src='/logo.png' alt='Logo' />
</Segment> <div className='text-right text-blue-400'>
<Divider className='mt-7' /> <a href='/logo.png' download='koreanbots.png'>
<h2 className='font-semibold text-3xl my-7'></h2> .png
<Segment> </a>
<> </div>
, , .
<div className='grid md:grid-cols-2 lg:grid-cols-4'>
<div>
<img src='/logo.png' alt='Logo' />
<div className='text-right text-blue-400'>
<a href='/logo.png' download='koreanbots.png'>.png</a>
</div> </div>
</div> </div>
</div> <h3 className='my-1 text-xl font-bold'></h3>
<h3 className='font-bold text-xl my-1'></h3> <p className='text-md my-1 font-bold'>영문: Uni Sans Heavy | 한글: Gugi</p>
<p className='font-bold text-md my-1'>영문: Uni Sans Heavy | 한글: Gugi</p> </>
</> </Segment>
</Segment> <Divider className='mt-7' />
<Divider className='mt-7' /> <h2 className='my-5 text-3xl font-semibold'></h2>
<h2 className='font-semibold text-3xl my-5'></h2> <div className='grid gap-4 md:grid-cols-2 lg:grid-cols-4'>
<div className='grid md:grid-cols-2 lg:grid-cols-4 gap-4'> {ThemeColors.map((el) => (
{ <ColorCard
ThemeColors.map(el => ( key={el.color}
<ColorCard key={el.color} header={el.name} first={el.rgb} second={el.hex} className={`bg-${el.color} ${el.color.includes('white') ? 'text-black' : 'text-white'}`} /> header={el.name}
)) first={el.rgb}
} second={el.hex}
className={`bg-${el.color} ${
el.color.includes('white') ? 'text-black' : 'text-white'
}`}
/>
))}
</div>
</div> </div>
</div> </Container>
</Container> </Docs>
</Docs> </div>
</div> )
} }
export default About export default About

View File

@ -31,10 +31,10 @@ const Container = dynamic(() => import('@components/Container'))
const Message = dynamic(() => import('@components/Message')) const Message = dynamic(() => import('@components/Message'))
const Captcha = dynamic(() => import('@components/Captcha')) const Captcha = dynamic(() => import('@components/Captcha'))
const AddBot:NextPage<AddBotProps> = ({ logged, user, csrfToken, theme }) => { const AddBot: NextPage<AddBotProps> = ({ logged, user, csrfToken, theme }) => {
const [ data, setData ] = useState<ResponseProps<SubmittedBot>>(null) const [data, setData] = useState<ResponseProps<SubmittedBot>>(null)
const [ captcha, setCaptcha ] = useState(false) const [captcha, setCaptcha] = useState(false)
const [ touchedSumbit, setTouched ] = useState(false) const [touchedSumbit, setTouched] = useState(false)
const captchaRef = useRef<HCaptcha>() const captchaRef = useRef<HCaptcha>()
const router = useRouter() const router = useRouter()
const initialValues: AddBotSubmit = { const initialValues: AddBotSubmit = {
@ -58,159 +58,334 @@ const AddBot:NextPage<AddBotProps> = ({ logged, user, csrfToken, theme }) => {
- -
- ?`, - ?`,
_csrf: csrfToken, _csrf: csrfToken,
_captcha: 'captcha' _captcha: 'captcha',
} }
function toLogin() { function toLogin() {
localStorage.redirectTo = window.location.href localStorage.redirectTo = window.location.href
redirectTo(router, 'login') redirectTo(router, 'login')
} }
async function submitBot(value: AddBotSubmit, token: string) { async function submitBot(value: AddBotSubmit, token: string) {
const res = await Fetch<SubmittedBot>(`/bots/${value.id}`, { method: 'POST', body: JSON.stringify(cleanObject<AddBotSubmit>({ ...value, _captcha: token})) }) const res = await Fetch<SubmittedBot>(`/bots/${value.id}`, {
method: 'POST',
body: JSON.stringify(cleanObject<AddBotSubmit>({ ...value, _captcha: token })),
})
setData(res) setData(res)
} }
if(!logged) return <Login> if (!logged)
<NextSeo title='새로운 봇 추가하기' description='자신의 봇을 한국 디스코드 리스트에 등록하세요.' openGraph={{ return (
title:'새로운 봇 추가하기', description: '자신의 봇을 한국 디스코드 리스트에 등록하세요.' <Login>
}} /> <NextSeo
</Login> title='새로운 봇 추가하기'
if(data?.data && data.code === 200) { description='자신의 봇을 한국 디스코드 리스트에 등록하세요.'
setTimeout( openGraph={{
() => redirectTo(router, `/pendingBots/${data.data.id}/${data.data.date}`), title: '새로운 봇 추가하기',
1_000 description: '자신의 봇을 한국 디스코드 리스트에 등록하세요.',
}}
/>
</Login>
) )
if (data?.data && data.code === 200) {
setTimeout(() => redirectTo(router, `/pendingBots/${data.data.id}/${data.data.date}`), 1_000)
} }
return ( return (
<Container paddingTop className='py-5'> <Container paddingTop className='py-5'>
<NextSeo title='새로운 봇 추가하기' description='자신의 봇을 한국 디스코드 리스트에 등록하세요.' openGraph={{ <NextSeo
title:'새로운 봇 추가하기', description: '자신의 봇을 한국 디스코드 리스트에 등록하세요.' title='새로운 봇 추가하기'
}} /> description='자신의 봇을 한국 디스코드 리스트에 등록하세요.'
openGraph={{
title: '새로운 봇 추가하기',
description: '자신의 봇을 한국 디스코드 리스트에 등록하세요.',
}}
/>
<h1 className='text-3xl font-bold'> </h1> <h1 className='text-3xl font-bold'> </h1>
<div className='mt-1 mb-5'> <div className='mb-5 mt-1'>
, <span className='font-semibold'>{user.tag === '0' ? `@${user.username}` : `${user.username}#${user.tag}`}</span>! <a role='button' tabIndex={0} onKeyDown={toLogin} onClick={toLogin} className='text-discord-blurple cursor-pointer outline-none'> ?</a> ,{' '}
<span className='font-semibold'>
{user.tag === '0' ? `@${user.username}` : `${user.username}#${user.tag}`}
</span>
!{' '}
<a
role='button'
tabIndex={0}
onKeyDown={toLogin}
onClick={toLogin}
className='cursor-pointer text-discord-blurple outline-none'
>
?
</a>
</div> </div>
{ {data ? (
data ? data.code == 200 && data.data ? <Message type='success'> data.code == 200 && data.data ? (
<h2 className='text-lg font-extrabold'> !</h2> <Message type='success'>
<p> ! .</p> <h2 className='text-lg font-extrabold'> !</h2>
</Message> : <Message type='error'> <p> ! .</p>
<h2 className='text-lg font-extrabold'>{data.message || '오류가 발생했습니다.'}</h2> </Message>
<ul className='list-disc list-inside'> ) : (
{data.errors?.map((el, n) => <li key={n}>{el}</li>)} <Message type='error'>
</ul> <h2 className='text-lg font-extrabold'>{data.message || '오류가 발생했습니다.'}</h2>
<ul className='list-inside list-disc'>
</Message> : <></> {data.errors?.map((el, n) => <li key={n}>{el}</li>)}
} </ul>
<Formik initialValues={initialValues} </Message>
)
) : (
<></>
)}
<Formik
initialValues={initialValues}
validationSchema={AddBotSubmitSchema} validationSchema={AddBotSubmitSchema}
onSubmit={() => setCaptcha(true)}> onSubmit={() => setCaptcha(true)}
>
{({ errors, touched, values, isValid, setFieldTouched, setFieldValue }) => ( {({ errors, touched, values, isValid, setFieldTouched, setFieldValue }) => (
<Form> <Form>
<div className='py-3'> <div className='py-3'>
<Message type='warning'> <Message type='warning'>
<h2 className='text-lg font-extrabold'> !</h2> <h2 className='text-lg font-extrabold'>
<ul className='list-disc list-inside'> !
<li><Link </h2>
href='/discord' <ul className='list-inside list-disc'>
rel='noreferrer' <li>
target='_blank' <Link
className='text-blue-500 hover:text-blue-600'> </Link> ?</li> href='/discord'
<li> <Link rel='noreferrer'
href='/guidelines' target='_blank'
rel='noreferrer' className='text-blue-500 hover:text-blue-600'
target='_blank' >
className='text-blue-500 hover:text-blue-600'></Link> ?</li>
<li> ? , .</li> </Link>
?
</li>
<li>
{' '}
<Link
href='/guidelines'
rel='noreferrer'
target='_blank'
className='text-blue-500 hover:text-blue-600'
>
</Link>
?
</li>
<li>
? ,
.
</li>
<li>, API에 .</li> <li>, API에 .</li>
</ul> </ul>
</Message> </Message>
</div> </div>
<Label For='agree' error={errors.agree && touched.agree ? errors.agree : null} grid={false}> <Label
For='agree'
error={errors.agree && touched.agree ? errors.agree : null}
grid={false}
>
<div className='flex items-center'> <div className='flex items-center'>
<CheckBox name='agree' /> <CheckBox name='agree' />
<strong className='text-sm ml-2'> , .</strong> <strong className='ml-2 text-sm'>
,
.
</strong>
</div> </div>
</Label> </Label>
<Divider /> <Divider />
<Advertisement /> <Advertisement />
<Label For='id' label='봇 ID' labelDesc='봇의 클라이언트 ID를 의미합니다.' error={errors.id && touched.id ? errors.id : null} short required> <Label
For='id'
label='봇 ID'
labelDesc='봇의 클라이언트 ID를 의미합니다.'
error={errors.id && touched.id ? errors.id : null}
short
required
>
<Input name='id' placeholder='653534001742741552' /> <Input name='id' placeholder='653534001742741552' />
</Label> </Label>
<Label For='prefix' label='접두사' labelDesc='봇의 사용시 앞 쪽에 붙은 기호를 의미합니다. (Prefix)' error={errors.prefix && touched.prefix ? errors.prefix : null} short required> <Label
For='prefix'
label='접두사'
labelDesc='봇의 사용시 앞 쪽에 붙은 기호를 의미합니다. (Prefix)'
error={errors.prefix && touched.prefix ? errors.prefix : null}
short
required
>
<Input name='prefix' placeholder='!' /> <Input name='prefix' placeholder='!' />
</Label> </Label>
<Label For='library' label='라이브러리' labelDesc='봇에 사용된 라이브러리를 선택해주세요. 해당되는 라이브러리가 없다면 기타를 선택해주세요.' short required error={errors.library && touched.library ? errors.library : null}> <Label
<Select options={library.map(el=> ({ label: el, value: el }))} handleChange={(value) => setFieldValue('library', value.value)} handleTouch={() => setFieldTouched('library', true)} /> For='library'
label='라이브러리'
labelDesc='봇에 사용된 라이브러리를 선택해주세요. 해당되는 라이브러리가 없다면 기타를 선택해주세요.'
short
required
error={errors.library && touched.library ? errors.library : null}
>
<Select
options={library.map((el) => ({ label: el, value: el }))}
handleChange={(value) => setFieldValue('library', value.value)}
handleTouch={() => setFieldTouched('library', true)}
/>
</Label> </Label>
<Label For='category' label='카테고리' labelDesc='봇에 해당되는 카테고리를 선택해주세요' required error={errors.category && touched.category ? errors.category as string : null}> <Label
<Selects options={botCategories.map(el=> ({ label: el, value: el }))} handleChange={(value) => { For='category'
setFieldValue('category', value.map(v=> v.value)) label='카테고리'
}} handleTouch={() => setFieldTouched('category', true)} values={values.category as string[]} setValues={(value) => setFieldValue('category', value)} /> labelDesc='봇에 해당되는 카테고리를 선택해주세요'
<p className='text-gray-400 mt-1 text-sm'> 3 . . <strong> .</strong><br/> required
<a className='text-blue-500 hover:text-blue-400' href='https://contents.koreanbots.dev/categories'></a> !</p> error={errors.category && touched.category ? (errors.category as string) : null}
>
<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)}
/>
<p className='mt-1 text-sm text-gray-400'>
3 . .{' '}
<strong> .</strong>
<br />
<a
className='text-blue-500 hover:text-blue-400'
href='https://contents.koreanbots.dev/categories'
>
</a>
!
</p>
</Label> </Label>
<Divider /> <Divider />
<Label For='website' label='웹사이트' labelDesc='봇의 웹사이트를 작성해주세요.' error={errors.website && touched.website ? errors.website : null}> <Label
For='website'
label='웹사이트'
labelDesc='봇의 웹사이트를 작성해주세요.'
error={errors.website && touched.website ? errors.website : null}
>
<Input name='website' placeholder='https://koreanbots.dev' /> <Input name='website' placeholder='https://koreanbots.dev' />
</Label> </Label>
<Label For='git' label='Git URL' labelDesc='봇 소스코드의 Git 주소를 입력해주세요. (오픈소스인 경우)' error={errors.git && touched.git ? errors.git : null}> <Label
<Input name='git' placeholder='https://github.com/koreanbots/koreanbots'/> For='git'
label='Git URL'
labelDesc='봇 소스코드의 Git 주소를 입력해주세요. (오픈소스인 경우)'
error={errors.git && touched.git ? errors.git : null}
>
<Input name='git' placeholder='https://github.com/koreanbots/koreanbots' />
</Label> </Label>
<Label For='inviteLink' label='초대링크' labelDesc='봇의 초대링크입니다. 비워두시면 자동으로 생성합니다.' error={errors.url && touched.url ? errors.url : null}> <Label
<Input name='url' placeholder='https://discord.com/oauth2/authorize?client_id=653534001742741552&scope=bot&permissions=0' /> For='inviteLink'
<span className='text-gray-400 mt-1 text-sm'> label='초대링크'
labelDesc='봇의 초대링크입니다. 비워두시면 자동으로 생성합니다.'
error={errors.url && touched.url ? errors.url : null}
>
<Input
name='url'
placeholder='https://discord.com/oauth2/authorize?client_id=653534001742741552&scope=bot&permissions=0'
/>
<span className='mt-1 text-sm text-gray-400'>
<Link <Link
href='/calculator' href='/calculator'
rel='noreferrer' rel='noreferrer'
target='_blank' target='_blank'
className='text-blue-500 hover:text-blue-400'> className='text-blue-500 hover:text-blue-400'
>
</Link> !
</Link>
!
</span> </span>
</Label> </Label>
<Label For='discord' label='지원 디스코드 서버' labelDesc='봇의 지원 디스코드 서버를 입력해주세요. (봇에 대해 도움을 받을 수 있는 공간입니다.)' error={errors.discord && touched.discord ? errors.discord : null} short> <Label
For='discord'
label='지원 디스코드 서버'
labelDesc='봇의 지원 디스코드 서버를 입력해주세요. (봇에 대해 도움을 받을 수 있는 공간입니다.)'
error={errors.discord && touched.discord ? errors.discord : null}
short
>
<div className='flex items-center'> <div className='flex items-center'>
discord.gg/<Input name='discord' placeholder='JEh53MQ' /> discord.gg/
<Input name='discord' placeholder='JEh53MQ' />
</div> </div>
</Label> </Label>
<Divider /> <Divider />
<Label For='intro' label='봇 소개' labelDesc='봇을 소개할 수 있는 간단한 설명을 적어주세요. (최대 60자)' error={errors.intro && touched.intro ? errors.intro : null} required> <Label
For='intro'
label='봇 소개'
labelDesc='봇을 소개할 수 있는 간단한 설명을 적어주세요. (최대 60자)'
error={errors.intro && touched.intro ? errors.intro : null}
required
>
<Input name='intro' placeholder='국내 봇을 한 곳에서.' /> <Input name='intro' placeholder='국내 봇을 한 곳에서.' />
</Label> </Label>
<Label For='intro' label='봇 설명' labelDesc={<> ! ( 1500)<br/> !</>} error={errors.desc && touched.desc ? errors.desc : null} required> <Label
<TextArea max={1500} name='desc' placeholder='봇에 대해 최대한 자세히 설명해주세요!' theme={theme === 'dark' ? 'dark' : 'light'} value={values.desc} setValue={(value) => setFieldValue('desc', value)} /> For='intro'
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>
<Label For='preview' label='설명 미리보기' labelDesc='다음 결과는 실제와 다를 수 있습니다.'> <Label
For='preview'
label='설명 미리보기'
labelDesc='다음 결과는 실제와 다를 수 있습니다.'
>
<Segment> <Segment>
<Markdown text={values.desc} /> <Markdown text={values.desc} />
</Segment> </Segment>
</Label> </Label>
<Divider /> <Divider />
<p className='text-base mt-2 mb-5'> <p className='mb-5 mt-2 text-base'>
<span className='text-red-500 font-semibold'> *</span> = <span className='font-semibold text-red-500'> *</span> =
</p> </p>
{ {captcha ? (
captcha ? <Captcha ref={captchaRef} dark={theme === 'dark'} onVerify={(token) => { <Captcha
submitBot(values, token) ref={captchaRef}
window.scrollTo({ top: 0 }) dark={theme === 'dark'}
setCaptcha(false) onVerify={(token) => {
captchaRef?.current?.resetCaptcha() submitBot(values, token)
}} /> : <> window.scrollTo({ top: 0 })
{ setCaptcha(false)
touchedSumbit && !isValid && <div className='my-1 text-red-500 text-xs font-light'> . .</div> captchaRef?.current?.resetCaptcha()
} }}
<Button type='submit' onClick={() => { />
setTouched(true) ) : (
if(!isValid) window.scrollTo({ top: 0 }) <>
} }> {touchedSumbit && !isValid && (
<div className='my-1 text-xs font-light text-red-500'>
. .
</div>
)}
<Button
type='submit'
onClick={() => {
setTouched(true)
if (!isValid) window.scrollTo({ top: 0 })
}}
>
<> <>
<i className='far fa-paper-plane'/> <i className='far fa-paper-plane' />
</> </>
</Button> </Button>
</> </>
} )}
</Form> </Form>
)} )}
</Formik> </Formik>
@ -222,7 +397,13 @@ const AddBot:NextPage<AddBotProps> = ({ logged, user, csrfToken, theme }) => {
export const getServerSideProps = async (ctx: NextPageContext) => { export const getServerSideProps = async (ctx: NextPageContext) => {
const parsed = parseCookie(ctx.req) const parsed = parseCookie(ctx.req)
const user = await get.Authorization(parsed?.token) const user = await get.Authorization(parsed?.token)
return { props: { logged: !!user, user: await get.user.load(user || ''), csrfToken: getToken(ctx.req, ctx.res) } } return {
props: {
logged: !!user,
user: await get.user.load(user || ''),
csrfToken: getToken(ctx.req, ctx.res),
},
}
} }
interface AddBotProps { interface AddBotProps {

View File

@ -30,10 +30,17 @@ const Container = dynamic(() => import('@components/Container'))
const Message = dynamic(() => import('@components/Message')) const Message = dynamic(() => import('@components/Message'))
const Captcha = dynamic(() => import('@components/Captcha')) const Captcha = dynamic(() => import('@components/Captcha'))
const AddServer:NextPage<AddServerProps> = ({ logged, user, csrfToken, server, serverData, theme }) => { const AddServer: NextPage<AddServerProps> = ({
const [ data, setData ] = useState<ResponseProps<AddServerSubmit>>(null) logged,
const [ captcha, setCaptcha ] = useState(false) user,
const [ touchedSumbit, setTouched ] = useState(false) 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 captchaRef = useRef<HCaptcha>()
const router = useRouter() const router = useRouter()
const initialValues: AddServerSubmit = { const initialValues: AddServerSubmit = {
@ -58,151 +65,284 @@ const AddServer:NextPage<AddServerProps> = ({ logged, user, csrfToken, server, s
- ?`, - ?`,
category: [], category: [],
_csrf: csrfToken, _csrf: csrfToken,
_captcha: 'captcha' _captcha: 'captcha',
} }
function toLogin() { function toLogin() {
localStorage.redirectTo = window.location.href localStorage.redirectTo = window.location.href
redirectTo(router, 'login') redirectTo(router, 'login')
} }
async function submitServer(id: string, value: AddServerSubmit, token: string) { 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 })) }) const res = await Fetch<AddServerSubmit>(`/servers/${id}`, {
method: 'POST',
body: JSON.stringify(cleanObject<AddServerSubmit>({ ...value, _captcha: token })),
})
setData(res) setData(res)
} }
if(!logged) return <Login> if (!logged)
<NextSeo title='새로운 서버 추가하기' description='자신의 서버를 한국 디스코드 리스트에 등록하세요.' openGraph={{ return (
title:'새로운 서버 추가하기', description: '자신의 서버를 한국 디스코드 리스트에 등록하세요.' <Login>
}} /> <NextSeo
</Login> title='새로운 서버 추가하기'
if(data?.data && data.code == 200) { description='자신의 서버를 한국 디스코드 리스트에 등록하세요.'
setTimeout( openGraph={{
() => redirectTo(router, `/servers/${router.query.id}`), title: '새로운 서버 추가하기',
1_000 description: '자신의 서버를 한국 디스코드 리스트에 등록하세요.',
}}
/>
</Login>
) )
if (data?.data && data.code == 200) {
setTimeout(() => redirectTo(router, `/servers/${router.query.id}`), 1_000)
} }
return ( return (
<Container paddingTop className='py-5'> <Container paddingTop className='py-5'>
<NextSeo title='새로운 서버 추가하기' description='자신의 서버를 한국 디스코드 리스트에 등록하세요.' openGraph={{ <NextSeo
title:'새로운 서버 추가하기', description: '자신의 서버를 한국 디스코드 리스트에 등록하세요.' title='새로운 서버 추가하기'
}} /> description='자신의 서버를 한국 디스코드 리스트에 등록하세요.'
openGraph={{
title: '새로운 서버 추가하기',
description: '자신의 서버를 한국 디스코드 리스트에 등록하세요.',
}}
/>
<h1 className='text-3xl font-bold'> </h1> <h1 className='text-3xl font-bold'> </h1>
<div className='mt-1 mb-5'> <div className='mb-5 mt-1'>
, <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> ,{' '}
<span className='font-semibold'>
{user.username}#{user.tag}
</span>
!{' '}
<a
role='button'
tabIndex={0}
onKeyDown={toLogin}
onClick={toLogin}
className='cursor-pointer text-discord-blurple outline-none'
>
?
</a>
</div> </div>
{ {data ? (
data ? data.code == 200 && data.data ? <Message type='success'> data.code == 200 && data.data ? (
<h2 className='text-lg font-extrabold'> !</h2> <Message type='success'>
<p> ! !</p> <h2 className='text-lg font-extrabold'> !</h2>
</Message> : <Message type='error'> <p> ! !</p>
<h2 className='text-lg font-extrabold'>{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-extrabold'> .</h2>
</Message> :
!serverData ? <Message type='info'>
<h2 className='text-lg font-extrabold'> .</h2>
<p> .</p>
<p> 1 .</p>
</Message> </Message>
: serverData.admins.includes(user.id) || serverData.owner.includes(user.id) ? <Formik initialValues={initialValues} ) : (
validationSchema={AddServerSubmitSchema} <Message type='error'>
onSubmit={() => setCaptcha(true)}> <h2 className='text-lg font-extrabold'>{data.message || '오류가 발생했습니다.'}</h2>
{({ errors, touched, values, isValid, setFieldTouched, setFieldValue }) => ( <ul className='list-inside list-disc'>
<Form> {data.errors?.map((el, n) => <li key={n}>{el}</li>)}
<div className='py-3'> </ul>
<Message type='warning'> </Message>
<h2 className='text-lg font-extrabold'> !</h2> )
<ul className='list-disc list-inside'> ) : (
<li><Link <></>
href='/discord' )}
rel='noreferrer' {server ? (
target='_blank' <Message type='warning'>
className='text-blue-500 hover:text-blue-600'> </Link> .</li> <h2 className='text-lg font-extrabold'> .</h2>
<li> <Link </Message>
href='/guidelines' ) : !serverData ? (
rel='noreferrer' <Message type='info'>
target='_blank' <h2 className='text-lg font-extrabold'> .</h2>
className='text-blue-500 hover:text-blue-600'></Link> ?</li> <p> .</p>
<li> <strong></strong> .</li> <p> 1 .</p>
<li> .</li> </Message>
<li>, API에 .</li> ) : serverData.admins.includes(user.id) || serverData.owner.includes(user.id) ? (
</ul> <Formik
</Message> initialValues={initialValues}
</div> validationSchema={AddServerSubmitSchema}
<Label For='agree' error={errors.agree && touched.agree ? errors.agree : null} grid={false}> onSubmit={() => setCaptcha(true)}
<div className='flex items-center'> >
<CheckBox name='agree' /> {({ errors, touched, values, isValid, setFieldTouched, setFieldValue }) => (
<strong className='text-sm ml-2'> , .</strong> <Form>
<div className='py-3'>
<Message type='warning'>
<h2 className='text-lg font-extrabold'>
!
</h2>
<ul className='list-inside list-disc'>
<li>
<Link
href='/discord'
rel='noreferrer'
target='_blank'
className='text-blue-500 hover:text-blue-600'
>
</Link>
.
</li>
<li>
{' '}
<Link
href='/guidelines'
rel='noreferrer'
target='_blank'
className='text-blue-500 hover:text-blue-600'
>
</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='ml-2 text-sm'>
,
.
</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='mt-1 text-sm text-gray-400'>
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='mb-5 mt-2 text-base'>
<span className='font-semibold text-red-500'> *</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-xs font-light text-red-500'>
. .
</div> </div>
</Label> )}
<Divider /> <Button
<Label For='id' label='서버' labelDesc='등록하시는 대상 서버 입니다.'> type='submit'
<p> onClick={() => {
<strong>{serverData.name}</strong> setTouched(true)
<br/> ID: {router.query.id} if (!isValid) window.scrollTo({ top: 0 })
</p> }}
</Label> >
<Divider /> <>
<Label For='category' label='카테고리' labelDesc='서버에 해당되는 카테고리를 선택해주세요' required error={errors.category && touched.category ? errors.category as string : null}> <i className='far fa-paper-plane' />
<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>
</> </>
} </Button>
</Form> </>
)} )}
</Formik> </Form>
: <Forbidden /> )}
} </Formik>
) : (
<Forbidden />
)}
</Container> </Container>
) )
} }
@ -212,12 +352,16 @@ export const getServerSideProps = async (ctx: NextPageContext) => {
const user = await get.Authorization(parsed?.token) const user = await get.Authorization(parsed?.token)
const server = (await get.server.load(ctx.query.id as string)) || null const server = (await get.server.load(ctx.query.id as string)) || null
const serverData = (await get.serverData(ctx.query.id as string)) || null const serverData = (await get.serverData(ctx.query.id as string)) || null
return { props: { return {
logged: !!user, user: await get.user.load(user || ''), props: {
csrfToken: getToken(ctx.req, ctx.res), logged: !!user,
server, user: await get.user.load(user || ''),
serverData: (+new Date() - +new Date(serverData?.updatedAt)) < 2 * 60 * 1000 ? serverData : null csrfToken: getToken(ctx.req, ctx.res),
} } server,
serverData:
+new Date() - +new Date(serverData?.updatedAt) < 2 * 60 * 1000 ? serverData : null,
},
}
} }
interface AddServerProps { interface AddServerProps {
@ -229,4 +373,4 @@ interface AddServerProps {
theme: Theme theme: Theme
} }
export default AddServer export default AddServer

View File

@ -3,7 +3,7 @@ import dynamic from 'next/dynamic'
import { NextSeo } from 'next-seo' import { NextSeo } from 'next-seo'
import { get } from '@utils/Query' import { get } from '@utils/Query'
import { parseCookie} from '@utils/Tools' import { parseCookie } from '@utils/Tools'
import { RawGuild, ServerData, Theme, User } from '@types' import { RawGuild, ServerData, Theme, User } from '@types'
const Advertisement = dynamic(() => import('@components/Advertisement')) const Advertisement = dynamic(() => import('@components/Advertisement'))
@ -12,40 +12,71 @@ const ServerCard = dynamic(() => import('@components/ServerCard'))
const Login = dynamic(() => import('@components/Login')) const Login = dynamic(() => import('@components/Login'))
const Container = dynamic(() => import('@components/Container')) const Container = dynamic(() => import('@components/Container'))
const AddBot:NextPage<AddBotProps> = ({ logged, guilds }) => { const AddBot: NextPage<AddBotProps> = ({ logged, guilds }) => {
if(!logged) return <Login> if (!logged)
<NextSeo title='새로운 서버 추가하기' description='자신의 서버를 한국 디스코드 리스트에 등록하세요.' openGraph={{ return (
title:'새로운 서버 추가하기', description: '자신의 서버를 한국 디스코드 리스트에 등록하세요.' <Login>
}} /> <NextSeo
</Login> title='새로운 서버 추가하기'
return <Container paddingTop className='py-5'> description='자신의 서버를 한국 디스코드 리스트에 등록하세요.'
<NextSeo title='새로운 서버 추가하기' description='자신의 서버를 한국 디스코드 리스트에 등록하세요.' openGraph={{ openGraph={{
title:'새로운 서버 추가하기', description: '자신의 서버를 한국 디스코드 리스트에 등록하세요.' title: '새로운 서버 추가하기',
}} /> description: '자신의 서버를 한국 디스코드 리스트에 등록하세요.',
<h1 className='text-3xl font-bold'> </h1> }}
<p className='text-gray-400'> .</p> />
<p className='text-gray-400 pb-5'> . , 1 .</p> </Login>
<Advertisement /> )
<ResponsiveGrid> return (
{ <Container paddingTop className='py-5'>
guilds.sort((a ,b) => (+!!b.data || 0) - (+!!a.data || 0)).map(g => ( <NextSeo
<ServerCard type={g.exists ? 'manage' : 'add'} server={g} key={g.id} /> title='새로운 서버 추가하기'
)) description='자신의 서버를 한국 디스코드 리스트에 등록하세요.'
} openGraph={{
</ResponsiveGrid> title: '새로운 서버 추가하기',
<Advertisement /> description: '자신의 서버를 한국 디스코드 리스트에 등록하세요.',
</Container> }}
/>
<h1 className='text-3xl font-bold'> </h1>
<p className='text-gray-400'> .</p>
<p className='pb-5 text-gray-400'>
. , 1 .
</p>
<Advertisement />
<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>
<Advertisement />
</Container>
)
} }
export const getServerSideProps = async (ctx: NextPageContext) => { export const getServerSideProps = async (ctx: NextPageContext) => {
const parsed = parseCookie(ctx.req) const parsed = parseCookie(ctx.req)
const user = await get.Authorization(parsed?.token) 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 guilds = (await get.userGuilds.load(user || ''))
const server = (await get.server.load(g.id)) ?.filter((g) => g.permissions & 8 || g.owner)
const data = await get.serverData(g.id) .map(async (g) => {
return { ...g, ...(server || {}), ...((+new Date() - +new Date(data?.updatedAt)) < 2 * 60 * 1000 ? { data } : {}), members: data?.memberCount || null, exists: !!server } const server = await get.server.load(g.id)
}) const data = await get.serverData(g.id)
return { props: { logged: !!user || !!guilds, user: await get.user.load(user || ''), guilds: guilds ? (await Promise.all(guilds)).filter(g => !g?.exists) : null } } 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 { interface AddBotProps {
@ -53,7 +84,7 @@ interface AddBotProps {
user: User user: User
csrfToken: string csrfToken: string
theme: Theme theme: Theme
guilds: (RawGuild & { data: ServerData, exists?: boolean })[] guilds: (RawGuild & { data: ServerData; exists?: boolean })[]
} }
export default AddBot export default AddBot

View File

@ -13,8 +13,8 @@ import RequestHandler from '@utils/RequestHandler'
const Callback = RequestHandler().get(async (req: ApiRequest, res) => { const Callback = RequestHandler().get(async (req: ApiRequest, res) => {
const validate = await OauthCallbackSchema.validate(req.query) const validate = await OauthCallbackSchema.validate(req.query)
.then(r => r) .then((r) => r)
.catch(e => { .catch((e) => {
ResponseWrapper(res, { code: 400, errors: e.errors }) ResponseWrapper(res, { code: 400, errors: e.errors })
return null return null
}) })
@ -35,7 +35,7 @@ const Callback = RequestHandler().get(async (req: ApiRequest, res) => {
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
}, },
}).then(r => r.json()) }).then((r) => r.json())
if (token.error) return ResponseWrapper(res, { code: 400, errors: ['올바르지 않은 코드입니다.'] }) if (token.error) return ResponseWrapper(res, { code: 400, errors: ['올바르지 않은 코드입니다.'] })
const user: DiscordUserInfo = await fetch(DiscordEnpoints.Me, { const user: DiscordUserInfo = await fetch(DiscordEnpoints.Me, {
@ -43,7 +43,7 @@ const Callback = RequestHandler().get(async (req: ApiRequest, res) => {
headers: { headers: {
Authorization: `${token.token_type} ${token.access_token}`, Authorization: `${token.token_type} ${token.access_token}`,
}, },
}).then(r => r.json()) }).then((r) => r.json())
const userToken = await update.assignToken({ const userToken = await update.assignToken({
id: user.id, id: user.id,
@ -53,11 +53,13 @@ const Callback = RequestHandler().get(async (req: ApiRequest, res) => {
email: user.email, email: user.email,
username: user.username, username: user.username,
discriminator: user.discriminator, discriminator: user.discriminator,
verified: user.verified verified: user.verified,
}) })
if(userToken === 1) return res.redirect(301, 'https://docs.koreanbots.dev/bots/account/unverified') if (userToken === 1)
else if(userToken === 2) return res.redirect(301, 'https://docs.koreanbots.dev/bots/account/blocked') return res.redirect(301, 'https://docs.koreanbots.dev/bots/account/unverified')
else if (userToken === 2)
return res.redirect(301, 'https://docs.koreanbots.dev/bots/account/blocked')
const info = verify(userToken) const info = verify(userToken)
res.setHeader( res.setHeader(
'set-cookie', 'set-cookie',

View File

@ -10,8 +10,8 @@ import RequestHandler from '@utils/RequestHandler'
const Callback = RequestHandler().get(async (req: ApiRequest, res) => { const Callback = RequestHandler().get(async (req: ApiRequest, res) => {
const validate = await OauthCallbackSchema.validate(req.query) const validate = await OauthCallbackSchema.validate(req.query)
.then(r => r) .then((r) => r)
.catch(e => { .catch((e) => {
ResponseWrapper(res, { code: 400, errors: e.errors }) ResponseWrapper(res, { code: 400, errors: e.errors })
return null return null
}) })
@ -20,21 +20,29 @@ const Callback = RequestHandler().get(async (req: ApiRequest, res) => {
const user = await get.Authorization(req.cookies.token) const user = await get.Authorization(req.cookies.token)
if (!user) return ResponseWrapper(res, { code: 401 }) if (!user) return ResponseWrapper(res, { code: 401 })
const token: GithubTokenInfo = await fetch(SpecialEndPoints.Github.Token(process.env.GITHUB_CLIENT_ID, process.env.GITHUB_CLIENT_SECRET,req.query.code), { const token: GithubTokenInfo = await fetch(
method: 'POST', SpecialEndPoints.Github.Token(
headers: { process.env.GITHUB_CLIENT_ID,
Accept: 'application/json' process.env.GITHUB_CLIENT_SECRET,
}, req.query.code
}).then(r => r.json()) ),
{
method: 'POST',
headers: {
Accept: 'application/json',
},
}
).then((r) => r.json())
if (token.error) return ResponseWrapper(res, { code: 400, errors: ['올바르지 않은 코드입니다.'] }) if (token.error) return ResponseWrapper(res, { code: 400, errors: ['올바르지 않은 코드입니다.'] })
const github: { login: string } = await fetch(SpecialEndPoints.Github.Me, { const github: { login: string } = await fetch(SpecialEndPoints.Github.Me, {
headers: { headers: {
Authorization: `token ${token.access_token}` Authorization: `token ${token.access_token}`,
} },
}).then(r => r.json()) }).then((r) => r.json())
const result = await update.Github(user, github.login) const result = await update.Github(user, github.login)
if(result === 0) return ResponseWrapper(res, { code: 400, message: '이미 등록되어있는 깃허브 계정입니다.' }) if (result === 0)
return ResponseWrapper(res, { code: 400, message: '이미 등록되어있는 깃허브 계정입니다.' })
get.user.clear(user) get.user.clear(user)
res.redirect(301, '/panel') res.redirect(301, '/panel')
}) })

View File

@ -5,17 +5,15 @@ import ResponseWrapper from '@utils/ResponseWrapper'
import { get, update } from '@utils/Query' import { get, update } from '@utils/Query'
import { checkToken } from '@utils/Csrf' import { checkToken } from '@utils/Csrf'
const Github = RequestHandler().get(async (_req: NextApiRequest, res: NextApiResponse) => { const Github = RequestHandler()
res.redirect( .get(async (_req: NextApiRequest, res: NextApiResponse) => {
301, res.redirect(301, generateOauthURL('github', process.env.GITHUB_CLIENT_ID))
generateOauthURL('github', process.env.GITHUB_CLIENT_ID) })
)
})
.delete(async (req: DeleteApiRequest, res) => { .delete(async (req: DeleteApiRequest, res) => {
const user = await get.Authorization(req.cookies.token) const user = await get.Authorization(req.cookies.token)
if (!user) return ResponseWrapper(res, { code: 401 }) if (!user) return ResponseWrapper(res, { code: 401 })
const csrfValidated = checkToken(req, res, req.body._csrf) const csrfValidated = checkToken(req, res, req.body._csrf)
if(!csrfValidated) return if (!csrfValidated) return
await update.Github(user, null) await update.Github(user, null)
get.user.clear(user) get.user.clear(user)
return ResponseWrapper(res, { code: 200 }) return ResponseWrapper(res, { code: 200 })

View File

@ -11,7 +11,9 @@ const rateLimiter = rateLimit({
windowMs: 60 * 1000, windowMs: 60 * 1000,
max: 150, max: 150,
handler: async (_req, res) => { handler: async (_req, res) => {
const img = await get.images.user.load(DiscordEnpoints.CDN.default(Math.floor(Math.random() * 6), { format: 'png' })) const img = await get.images.user.load(
DiscordEnpoints.CDN.default(Math.floor(Math.random() * 6), { format: 'png' })
)
res.setHeader('Content-Type', 'image/png') res.setHeader('Content-Type', 'image/png')
res.setHeader('Cache-Control', 'no-cache') res.setHeader('Cache-Control', 'no-cache')
res.send(img) res.send(img)
@ -20,45 +22,57 @@ const rateLimiter = rateLimit({
skip: (_req, res) => { skip: (_req, res) => {
res.removeHeader('X-RateLimit-Global') res.removeHeader('X-RateLimit-Global')
return false return false
} },
}) })
const Avatar = RequestHandler() const Avatar = RequestHandler()
.get(rateLimiter) .get(rateLimiter)
.get(async(req: ApiRequest, res) => { .get(async (req: ApiRequest, res) => {
res.setHeader('Access-Control-Allow-Origin', process.env.KOREANBOTS_URL) res.setHeader('Access-Control-Allow-Origin', process.env.KOREANBOTS_URL)
const { id: param, size='256' } = req.query const { id: param, size = '256' } = req.query
const splitted = param.split('.') const splitted = param.split('.')
let ext = splitted[1] let ext = splitted[1]
const id = splitted[0] const id = splitted[0]
const validated = await ImageOptionsSchema.validate({ id, ext, size }, { abortEarly: false }).then(el=> el).catch(e=> { const validated = await ImageOptionsSchema.validate({ id, ext, size }, { abortEarly: false })
ResponseWrapper(res, { code: 400, errors: e.errors }) .then((el) => el)
return null .catch((e) => {
}) ResponseWrapper(res, { code: 400, errors: e.errors })
if(!validated) return return null
})
if (!validated) return
const user = await get.discord.user.load(id) const user = await get.discord.user.load(id)
let img: Buffer let img: Buffer
if(!user?.avatar) img = await get.images.user.load(DiscordEnpoints.CDN.default(user?.discriminator ? Number(user.discriminator) % 5 : Math.floor(Math.random() * 6), { format: 'png', size: validated.size })) if (!user?.avatar)
else img = await get.images.user.load(DiscordEnpoints.CDN.user(id, user.avatar, { format: validated.ext === 'gif' && !user.avatar.startsWith('a_') ? 'png' : validated.ext })) img = await get.images.user.load(
if(!img) { DiscordEnpoints.CDN.default(
img = await get.images.user.load(DiscordEnpoints.CDN.default(user.discriminator, { format: 'png', size: validated.size })) user?.discriminator ? Number(user.discriminator) % 5 : Math.floor(Math.random() * 6),
{ format: 'png', size: validated.size }
)
)
else
img = await get.images.user.load(
DiscordEnpoints.CDN.user(id, user.avatar, {
format: validated.ext === 'gif' && !user.avatar.startsWith('a_') ? 'png' : validated.ext,
})
)
if (!img) {
img = await get.images.user.load(
DiscordEnpoints.CDN.default(user.discriminator, { format: 'png', size: validated.size })
)
ext = 'png' ext = 'png'
} }
res.setHeader('Content-Type', `image/${ext}`) res.setHeader('Content-Type', `image/${ext}`)
res.setHeader('Cache-Control', 'public, max-age=86400') res.setHeader('Cache-Control', 'public, max-age=86400')
res.send(img) res.send(img)
}) })
interface ApiRequest extends NextApiRequest { interface ApiRequest extends NextApiRequest {
query: { query: {
id: string id: string
size?: '128' | '256' | '512' size?: '128' | '256' | '512'
} }
} }
export default Avatar export default Avatar

View File

@ -11,7 +11,9 @@ const rateLimiter = rateLimit({
windowMs: 60 * 1000, windowMs: 60 * 1000,
max: 150, max: 150,
handler: async (_req, res) => { handler: async (_req, res) => {
const img = await get.images.server.load(DiscordEnpoints.CDN.default(Math.floor(Math.random() * 6), { format: 'png' })) 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('Content-Type', 'image/png')
res.setHeader('Cache-Control', 'no-cache') res.setHeader('Cache-Control', 'no-cache')
res.send(img) res.send(img)
@ -20,45 +22,51 @@ const rateLimiter = rateLimit({
skip: (_req, res) => { skip: (_req, res) => {
res.removeHeader('X-RateLimit-Global') res.removeHeader('X-RateLimit-Global')
return false return false
} },
}) })
const Icon = RequestHandler() const Icon = RequestHandler()
.get(rateLimiter) .get(rateLimiter)
.get(async(req: ApiRequest, res) => { .get(async (req: ApiRequest, res) => {
res.setHeader('Access-Control-Allow-Origin', process.env.KOREANBOTS_URL) res.setHeader('Access-Control-Allow-Origin', process.env.KOREANBOTS_URL)
const { id: param, size='256' } = req.query const { id: param, size = '256' } = req.query
const splitted = param.split('.') const splitted = param.split('.')
let ext = splitted[1] let ext = splitted[1]
const id = splitted[0] const id = splitted[0]
const validated = await ImageOptionsSchema.validate({ id, ext, size }, { abortEarly: false }).then(el=> el).catch(e=> { const validated = await ImageOptionsSchema.validate({ id, ext, size }, { abortEarly: false })
ResponseWrapper(res, { code: 400, errors: e.errors }) .then((el) => el)
return null .catch((e) => {
}) ResponseWrapper(res, { code: 400, errors: e.errors })
if(!validated) return return null
})
if (!validated) return
const guild = await get.server.load(id) const guild = await get.server.load(id)
let img: Buffer let img: Buffer
if(!guild?.icon) img = await get.images.server.load(DiscordEnpoints.CDN.default(+id % 4)) 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 })) else
if(!img) { img = await get.images.server.load(
img = await get.images.server.load(DiscordEnpoints.CDN.default(+id % 4, { format: 'png', size: validated.size })) 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' ext = 'png'
} }
res.setHeader('Content-Type', `image/${ext}`) res.setHeader('Content-Type', `image/${ext}`)
res.setHeader('Cache-Control', 'public, max-age=86400') res.setHeader('Cache-Control', 'public, max-age=86400')
res.send(img) res.send(img)
}) })
interface ApiRequest extends NextApiRequest { interface ApiRequest extends NextApiRequest {
query: { query: {
id: string id: string
size?: '128' | '256' | '512' size?: '128' | '256' | '512'
} }
} }
export default Icon export default Icon

View File

@ -1 +1 @@
export { default } from './[...404]' export { default } from './[...404]'

View File

@ -1,4 +1,4 @@
import { NextApiRequest} from 'next' import { NextApiRequest } from 'next'
import rateLimit from 'express-rate-limit' import rateLimit from 'express-rate-limit'
import { EmbedBuilder } from 'discord.js' import { EmbedBuilder } from 'discord.js'
@ -18,43 +18,65 @@ const limiter = rateLimit({
keyGenerator: (req) => req.headers.authorization, keyGenerator: (req) => req.headers.authorization,
skip: (req, res) => { skip: (req, res) => {
res.removeHeader('X-RateLimit-Global') res.removeHeader('X-RateLimit-Global')
if(!req.headers.authorization) return true if (!req.headers.authorization) return true
else return false else return false
} },
}) })
const BotStats = RequestHandler() const BotStats = RequestHandler()
.post(limiter) .post(limiter)
.post(async (req: PostApiRequest, res) => { .post(async (req: PostApiRequest, res) => {
const bot = await get.BotAuthorization(req.headers.token) const bot = await get.BotAuthorization(req.headers.token)
if(!bot) return ResponseWrapper(res, { code: 401, version: 1 }) if (!bot) return ResponseWrapper(res, { code: 401, version: 1 })
const validated: BotStatUpdate = await BotStatUpdateSchema.validate(req.body, { abortEarly: false }) const validated: BotStatUpdate = await BotStatUpdateSchema.validate(req.body, {
.then(el => el) abortEarly: false,
.catch(e => { })
.then((el) => el)
.catch((e) => {
ResponseWrapper(res, { code: 400, errors: e.errors }) ResponseWrapper(res, { code: 400, errors: e.errors })
return null return null
}) })
if(!validated) return if (!validated) return
const botInfo = await get.bot.load(bot) const botInfo = await get.bot.load(bot)
if(!botInfo) return ResponseWrapper(res, { code: 404, message: '존재하지 않는 봇입니다.', version: 1 }) if (!botInfo)
if(botInfo.id !== bot) return ResponseWrapper(res, { code: 403, version: 1 }) return ResponseWrapper(res, { code: 404, message: '존재하지 않는 봇입니다.', version: 1 })
if (botInfo.id !== bot) return ResponseWrapper(res, { code: 403, version: 1 })
const d = await update.updateServer(botInfo.id, validated.servers, undefined) const d = await update.updateServer(botInfo.id, validated.servers, undefined)
if(d===1 || d===2) return ResponseWrapper(res, { code: 403, message: `서버 수를 ${[null, '1만', '100만'][d]} 이상으로 설정하실 수 없습니다. 문의해주세요.`, version: 1 }) if (d === 1 || d === 2)
return ResponseWrapper(res, {
code: 403,
message: `서버 수를 ${
[null, '1만', '100만'][d]
} . .`,
version: 1,
})
get.bot.clear(bot) get.bot.clear(bot)
await webhookClients.internal.statsLog.send({ await webhookClients.internal.statsLog.send({
content: `[BOT/STATS] <@${botInfo.id}> (${botInfo.id})\n${makeDiscordCodeblock(`${botInfo.servers > validated.servers ? '-' : '+'} ${botInfo.servers} -> ${validated.servers} (${botInfo.servers > validated.servers ? '▼' : '▲'}${Math.abs(validated.servers - botInfo.servers)})`, 'diff')}`, content: `[BOT/STATS] <@${botInfo.id}> (${botInfo.id})\n${makeDiscordCodeblock(
embeds: [new EmbedBuilder().setDescription(`${botInfo.name} - <@${botInfo.id}> ([${botInfo.id}](${KoreanbotsEndPoints.URL.bot(botInfo.id)}`)] `${botInfo.servers > validated.servers ? '-' : '+'} ${botInfo.servers} -> ${
validated.servers
} (${botInfo.servers > validated.servers ? '▼' : '▲'}${Math.abs(
validated.servers - botInfo.servers
)})`,
'diff'
)}`,
embeds: [
new EmbedBuilder().setDescription(
`${botInfo.name} - <@${botInfo.id}> ([${botInfo.id}](${KoreanbotsEndPoints.URL.bot(
botInfo.id
)}`
),
],
}) })
return ResponseWrapper(res, { code: 200, message: '성공적으로 업데이트 했습니다.', version: 1 }) return ResponseWrapper(res, { code: 200, message: '성공적으로 업데이트 했습니다.', version: 1 })
}) })
interface PostApiRequest extends NextApiRequest { interface PostApiRequest extends NextApiRequest {
headers: { headers: {
token: string token: string
} }
body: BotStatUpdate body: BotStatUpdate
} }
export default BotStats export default BotStats

View File

@ -5,23 +5,26 @@ import ResponseWrapper from '@utils/ResponseWrapper'
import Yup from '@utils/Yup' import Yup from '@utils/Yup'
import { VOTE_COOLDOWN } from '@utils/Constants' import { VOTE_COOLDOWN } from '@utils/Constants'
const BotVoted = RequestHandler() const BotVoted = RequestHandler().get(async (req: ApiRequest, res) => {
.get(async (req: ApiRequest, res) => { const bot = await get.BotAuthorization(req.headers.token)
const bot = await get.BotAuthorization(req.headers.token) if (!bot) return ResponseWrapper(res, { code: 401, version: 1 })
if(!bot) return ResponseWrapper(res, { code: 401, version: 1 }) const userID = await Yup.string()
const userID = await Yup.string().required().validate(req.query.id).then(el => el).catch(() => null) .required()
if(!userID) return ResponseWrapper(res, { code: 400, version: 1 }) .validate(req.query.id)
const result = await get.botVote(userID, bot) .then((el) => el)
return res.json({ code: 200, voted: +new Date() < result + VOTE_COOLDOWN }) .catch(() => null)
}) if (!userID) return ResponseWrapper(res, { code: 400, version: 1 })
const result = await get.botVote(userID, bot)
return res.json({ code: 200, voted: +new Date() < result + VOTE_COOLDOWN })
})
interface ApiRequest extends NextApiRequest { interface ApiRequest extends NextApiRequest {
headers: { headers: {
token: string token: string
} }
query: { query: {
id: string id: string
} }
} }
export default BotVoted export default BotVoted

View File

@ -17,8 +17,8 @@ const BotApplications = RequestHandler().patch(async (req: ApiRequest, res) => {
const csrfValidated = checkToken(req, res, req.body._csrf) const csrfValidated = checkToken(req, res, req.body._csrf)
if (!csrfValidated) return if (!csrfValidated) return
const validated = await DeveloperBotSchema.validate(req.body, { abortEarly: false }) const validated = await DeveloperBotSchema.validate(req.body, { abortEarly: false })
.then(el => el) .then((el) => el)
.catch(e => { .catch((e) => {
ResponseWrapper(res, { code: 400, errors: e.errors }) ResponseWrapper(res, { code: 400, errors: e.errors })
return null return null
}) })
@ -26,17 +26,24 @@ const BotApplications = RequestHandler().patch(async (req: ApiRequest, res) => {
if (!validated) return if (!validated) return
const bot = await get.bot.load(req.query.id) const bot = await get.bot.load(req.query.id)
if (!bot) return ResponseWrapper(res, { code: 404, message: '존재하지 않는 봇입니다.' }) if (!bot) return ResponseWrapper(res, { code: 404, message: '존재하지 않는 봇입니다.' })
if (!(bot.owners as User[]).find(el => el.id === user)) return ResponseWrapper(res, { code: 403 }) if (!(bot.owners as User[]).find((el) => el.id === user))
if(validated.webhookURL) { return ResponseWrapper(res, { code: 403 })
if (validated.webhookURL) {
const key = await verifyWebhook(validated.webhookURL) const key = await verifyWebhook(validated.webhookURL)
if(key === false) { if (key === false) {
return ResponseWrapper(res, { code: 400, message: '웹후크 주소를 검증할 수 없습니다.', errors: ['웹후크 주소가 올바른지 확인해주세요.\n웹후크 주소 검증에 대한 자세한 내용은 API 문서를 참고해주세요.'] }) return ResponseWrapper(res, {
code: 400,
message: '웹후크 주소를 검증할 수 없습니다.',
errors: [
'웹후크 주소가 올바른지 확인해주세요.\n웹후크 주소 검증에 대한 자세한 내용은 API 문서를 참고해주세요.',
],
})
} }
const client = webhookClients.bot.get(req.query.id) const client = webhookClients.bot.get(req.query.id)
if(client && validated.webhookURL !== client.url) { if (client && validated.webhookURL !== client.url) {
destroyWebhookClient(req.query.id, 'bot') destroyWebhookClient(req.query.id, 'bot')
} }
await update.webhook(req.query.id, 'bots', { await update.webhook(req.query.id, 'bots', {
url: validated.webhookURL, url: validated.webhookURL,
status: parseWebhookURL(validated.webhookURL) ? WebhookStatus.Discord : WebhookStatus.HTTP, status: parseWebhookURL(validated.webhookURL) ? WebhookStatus.Discord : WebhookStatus.HTTP,
failedSince: null, failedSince: null,
@ -44,7 +51,7 @@ const BotApplications = RequestHandler().patch(async (req: ApiRequest, res) => {
}) })
} else { } else {
destroyWebhookClient(req.query.id, 'bot') destroyWebhookClient(req.query.id, 'bot')
await update.webhook(req.query.id, 'bots', { await update.webhook(req.query.id, 'bots', {
url: null, url: null,
status: WebhookStatus.None, status: WebhookStatus.None,
failedSince: null, failedSince: null,

View File

@ -14,8 +14,8 @@ const ResetApplication = RequestHandler().post(async (req: ApiRequest, res) => {
const csrfValidated = checkToken(req, res, req.body._csrf) const csrfValidated = checkToken(req, res, req.body._csrf)
if (!csrfValidated) return if (!csrfValidated) return
const validated = await ResetTokenSchema.validate(req.body, { abortEarly: false }) const validated = await ResetTokenSchema.validate(req.body, { abortEarly: false })
.then(el => el) .then((el) => el)
.catch(e => { .catch((e) => {
ResponseWrapper(res, { code: 400, errors: e.errors }) ResponseWrapper(res, { code: 400, errors: e.errors })
return null return null
}) })
@ -23,7 +23,8 @@ const ResetApplication = RequestHandler().post(async (req: ApiRequest, res) => {
if (!validated) return if (!validated) return
const bot = await get.bot.load(req.query.id) const bot = await get.bot.load(req.query.id)
if (!bot) return ResponseWrapper(res, { code: 404, message: '존재하지 않는 봇입니다.' }) if (!bot) return ResponseWrapper(res, { code: 404, message: '존재하지 않는 봇입니다.' })
if (!(bot.owners as User[]).find(el => el.id === user)) return ResponseWrapper(res, { code: 403 }) if (!(bot.owners as User[]).find((el) => el.id === user))
return ResponseWrapper(res, { code: 403 })
const d = await update.resetBotToken(req.query.id, validated.token) const d = await update.resetBotToken(req.query.id, validated.token)
if (!d) return ResponseWrapper(res, { code: 500, message: '무언가 잘못되었습니다.' }) if (!d) return ResponseWrapper(res, { code: 500, message: '무언가 잘못되었습니다.' })
return ResponseWrapper(res, { code: 200, data: { token: d } }) return ResponseWrapper(res, { code: 200, data: { token: d } })

View File

@ -17,8 +17,8 @@ const ServerApplications = RequestHandler().patch(async (req: ApiRequest, res) =
const csrfValidated = checkToken(req, res, req.body._csrf) const csrfValidated = checkToken(req, res, req.body._csrf)
if (!csrfValidated) return if (!csrfValidated) return
const validated = await DeveloperServerSchema.validate(req.body, { abortEarly: false }) const validated = await DeveloperServerSchema.validate(req.body, { abortEarly: false })
.then(el => el) .then((el) => el)
.catch(e => { .catch((e) => {
ResponseWrapper(res, { code: 400, errors: e.errors }) ResponseWrapper(res, { code: 400, errors: e.errors })
return null return null
}) })
@ -27,16 +27,22 @@ const ServerApplications = RequestHandler().patch(async (req: ApiRequest, res) =
const server = await get.serverData(req.query.id) const server = await get.serverData(req.query.id)
if (!server) return ResponseWrapper(res, { code: 404, message: '존재하지 않는 서버입니다.' }) if (!server) return ResponseWrapper(res, { code: 404, message: '존재하지 않는 서버입니다.' })
if (![server.owner, ...server.admins].includes(user)) return ResponseWrapper(res, { code: 403 }) if (![server.owner, ...server.admins].includes(user)) return ResponseWrapper(res, { code: 403 })
if(validated.webhookURL) { if (validated.webhookURL) {
const key = await verifyWebhook(validated.webhookURL) const key = await verifyWebhook(validated.webhookURL)
if(key === false) { if (key === false) {
return ResponseWrapper(res, { code: 400, message: '웹후크 주소를 검증할 수 없습니다.', errors: ['웹후크 주소가 올바른지 확인해주세요.\n웹후크 주소 검증에 대한 자세한 내용은 API 문서를 참고해주세요.'] }) return ResponseWrapper(res, {
code: 400,
message: '웹후크 주소를 검증할 수 없습니다.',
errors: [
'웹후크 주소가 올바른지 확인해주세요.\n웹후크 주소 검증에 대한 자세한 내용은 API 문서를 참고해주세요.',
],
})
} }
const client = webhookClients.server.get(req.query.id) const client = webhookClients.server.get(req.query.id)
if(client && validated.webhookURL !== client.url) { if (client && validated.webhookURL !== client.url) {
destroyWebhookClient(req.query.id, 'server') destroyWebhookClient(req.query.id, 'server')
} }
await update.webhook(req.query.id, 'servers', { await update.webhook(req.query.id, 'servers', {
url: validated.webhookURL, url: validated.webhookURL,
status: parseWebhookURL(validated.webhookURL) ? WebhookStatus.Discord : WebhookStatus.HTTP, status: parseWebhookURL(validated.webhookURL) ? WebhookStatus.Discord : WebhookStatus.HTTP,
failedSince: null, failedSince: null,
@ -44,7 +50,7 @@ const ServerApplications = RequestHandler().patch(async (req: ApiRequest, res) =
}) })
} else { } else {
destroyWebhookClient(req.query.id, 'server') destroyWebhookClient(req.query.id, 'server')
await update.webhook(req.query.id, 'servers', { await update.webhook(req.query.id, 'servers', {
url: null, url: null,
status: WebhookStatus.None, status: WebhookStatus.None,
failedSince: null, failedSince: null,

View File

@ -12,8 +12,8 @@ const ResetApplication = RequestHandler().post(async (req: ApiRequest, res) => {
const csrfValidated = checkToken(req, res, req.body._csrf) const csrfValidated = checkToken(req, res, req.body._csrf)
if (!csrfValidated) return if (!csrfValidated) return
const validated = await ResetTokenSchema.validate(req.body, { abortEarly: false }) const validated = await ResetTokenSchema.validate(req.body, { abortEarly: false })
.then(el => el) .then((el) => el)
.catch(e => { .catch((e) => {
ResponseWrapper(res, { code: 400, errors: e.errors }) ResponseWrapper(res, { code: 400, errors: e.errors })
return null return null
}) })
@ -21,8 +21,14 @@ const ResetApplication = RequestHandler().post(async (req: ApiRequest, res) => {
if (!validated) return if (!validated) return
const server = await get.server.load(req.query.id) const server = await get.server.load(req.query.id)
if (!server) return ResponseWrapper(res, { code: 404, message: '존재하지 않는 서버입니다.' }) if (!server) return ResponseWrapper(res, { code: 404, message: '존재하지 않는 서버입니다.' })
if(server.state === 'unreachable') return ResponseWrapper(res, { code: 400, message: '서버 정보를 불러올 수 없습니다.', errors: ['서버에서 봇이 추방되었거나, 봇이 오프라인이여서 서버 정보를 갱신할 수 없습니다.'] }) if (server.state === 'unreachable')
if (!(await get.serverOwners(server.id)).find(el => el.id === user)) return ResponseWrapper(res, { code: 403 }) 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) const d = await update.resetServerToken(req.query.id, validated.token)
if (!d) return ResponseWrapper(res, { code: 500, message: '무언가 잘못되었습니다.' }) if (!d) return ResponseWrapper(res, { code: 500, message: '무언가 잘못되었습니다.' })
return ResponseWrapper(res, { code: 200, data: { token: d } }) return ResponseWrapper(res, { code: 200, data: { token: d } })

View File

@ -6,10 +6,23 @@ import tracer from 'dd-trace'
import { CaptchaVerify, get, put, remove, update } from '@utils/Query' import { CaptchaVerify, get, put, remove, update } from '@utils/Query'
import ResponseWrapper from '@utils/ResponseWrapper' import ResponseWrapper from '@utils/ResponseWrapper'
import { checkToken } from '@utils/Csrf' import { checkToken } from '@utils/Csrf'
import { AddBotSubmit, AddBotSubmitSchema, CsrfCaptcha, ManageBot, ManageBotSchema } from '@utils/Yup' import {
AddBotSubmit,
AddBotSubmitSchema,
CsrfCaptcha,
ManageBot,
ManageBotSchema,
} from '@utils/Yup'
import RequestHandler from '@utils/RequestHandler' import RequestHandler from '@utils/RequestHandler'
import { User } from '@types' import { User } from '@types'
import { checkUserFlag, diff, inspect, makeDiscordCodeblock, objectDiff, serialize } from '@utils/Tools' import {
checkUserFlag,
diff,
inspect,
makeDiscordCodeblock,
objectDiff,
serialize,
} from '@utils/Tools'
import { discordLog, getMainGuild, webhookClients } from '@utils/DiscordBot' import { discordLog, getMainGuild, webhookClients } from '@utils/DiscordBot'
import { KoreanbotsEndPoints } from '@utils/Constants' import { KoreanbotsEndPoints } from '@utils/Constants'
@ -21,7 +34,7 @@ const patchLimiter = rateLimit({
skip: (_req, res) => { skip: (_req, res) => {
res.removeHeader('X-RateLimit-Global') res.removeHeader('X-RateLimit-Global')
return false return false
} },
}) })
const Bots = RequestHandler() const Bots = RequestHandler()
.get(async (req: GetApiRequest, res) => { .get(async (req: GetApiRequest, res) => {
@ -38,8 +51,8 @@ const Bots = RequestHandler()
if (!csrfValidated) return if (!csrfValidated) return
const validated = await AddBotSubmitSchema.validate(req.body, { abortEarly: false }) const validated = await AddBotSubmitSchema.validate(req.body, { abortEarly: false })
.then(el => el) .then((el) => el)
.catch(e => { .catch((e) => {
ResponseWrapper(res, { code: 400, errors: e.errors }) ResponseWrapper(res, { code: 400, errors: e.errors })
return null return null
}) })
@ -48,7 +61,7 @@ const Bots = RequestHandler()
if (validated.id !== req.query.id) if (validated.id !== req.query.id)
return ResponseWrapper(res, { code: 400, errors: ['요청 주소와 Body의 정보가 다릅니다.'] }) return ResponseWrapper(res, { code: 400, errors: ['요청 주소와 Body의 정보가 다릅니다.'] })
const captcha = await CaptchaVerify(validated._captcha) const captcha = await CaptchaVerify(validated._captcha)
if(!captcha) return ResponseWrapper(res, { code: 400, message: '캡챠 검증에 실패하였습니다.' }) if (!captcha) return ResponseWrapper(res, { code: 400, message: '캡챠 검증에 실패하였습니다.' })
const result = await put.submitBot(user, validated) const result = await put.submitBot(user, validated)
if (result === 1) if (result === 1)
return ResponseWrapper(res, { return ResponseWrapper(res, {
@ -82,30 +95,53 @@ const Bots = RequestHandler()
return ResponseWrapper(res, { return ResponseWrapper(res, {
code: 403, code: 403,
message: '더 이상 해당 봇에 대한 심사 요청을 하실 수 없습니다.', message: '더 이상 해당 봇에 대한 심사 요청을 하실 수 없습니다.',
errors: ['해당 봇은 심사에서 3회 이상 거부되었습니다. 더 이상의 심사를 요청하실 수 없습니다.', '이의 제기를 원하시는 경우 디스코드 서버를 통해 문의해주세요.'], errors: [
'해당 봇은 심사에서 3회 이상 거부되었습니다. 더 이상의 심사를 요청하실 수 없습니다.',
'이의 제기를 원하시는 경우 디스코드 서버를 통해 문의해주세요.',
],
}) })
get.botSubmits.clear(user) get.botSubmits.clear(user)
await discordLog('BOT/SUBMIT', user, new EmbedBuilder().setDescription(`[${result.id}/${result.date}](${KoreanbotsEndPoints.URL.submittedBot(result.id, result.date)})`), { await discordLog(
content: inspect(serialize(result)), 'BOT/SUBMIT',
format: 'js' user,
}) new EmbedBuilder().setDescription(
`[${result.id}/${result.date}](${KoreanbotsEndPoints.URL.submittedBot(
result.id,
result.date
)})`
),
{
content: inspect(serialize(result)),
format: 'js',
}
)
const userinfo = await get.user.load(user) const userinfo = await get.user.load(user)
await webhookClients.internal.reviewLog.send({ await webhookClients.internal.reviewLog.send({
embeds: [ embeds: [
new EmbedBuilder() new EmbedBuilder()
.setAuthor({ .setAuthor({
name: userinfo.tag === '0' ? `${userinfo.globalName} (@${userinfo.username})` : `${userinfo.username}#${userinfo.tag}`, name:
iconURL: KoreanbotsEndPoints.URL.root + KoreanbotsEndPoints.CDN.avatar(userinfo.id, { format: 'png', size: 256 }), userinfo.tag === '0'
url: KoreanbotsEndPoints.URL.user(userinfo.id) ? `${userinfo.globalName} (@${userinfo.username})`
: `${userinfo.username}#${userinfo.tag}`,
iconURL:
KoreanbotsEndPoints.URL.root +
KoreanbotsEndPoints.CDN.avatar(userinfo.id, { format: 'png', size: 256 }),
url: KoreanbotsEndPoints.URL.user(userinfo.id),
}) })
.setTitle('대기 중') .setTitle('대기 중')
.setColor(Colors.Grey) .setColor(Colors.Grey)
.setDescription(`[${result.id}/${result.date}](${KoreanbotsEndPoints.URL.submittedBot(result.id, result.date)})`) .setDescription(
.setTimestamp() `[${result.id}/${result.date}](${KoreanbotsEndPoints.URL.submittedBot(
] result.id,
result.date
)})`
)
.setTimestamp(),
],
}) })
tracer.trace('botSubmits.submitted', span => { tracer.trace('botSubmits.submitted', (span) => {
span.setTag('id', result.id) span.setTag('id', result.id)
span.setTag('date', result.date) span.setTag('date', result.date)
span.setTag('user', userinfo.id) span.setTag('user', userinfo.id)
@ -116,68 +152,114 @@ const Bots = RequestHandler()
const user = await get.Authorization(req.cookies.token) const user = await get.Authorization(req.cookies.token)
if (!user) return ResponseWrapper(res, { code: 401 }) if (!user) return ResponseWrapper(res, { code: 401 })
const bot = await get.bot.load(req.query.id) const bot = await get.bot.load(req.query.id)
if(!bot) return ResponseWrapper(res, { code: 404, message: '존재하지 않는 봇입니다.' }) if (!bot) return ResponseWrapper(res, { code: 404, message: '존재하지 않는 봇입니다.' })
if((bot.owners as User[])[0].id !== user) return ResponseWrapper(res, { code: 403 }) if ((bot.owners as User[])[0].id !== user) return ResponseWrapper(res, { code: 403 })
const userInfo = await get.user.load(user) const userInfo = await get.user.load(user)
if(['reported', 'blocked', 'archived'].includes(bot.state) && !checkUserFlag(userInfo?.flags, 'staff')) return ResponseWrapper(res, { code: 403, message: '해당 봇은 수정할 수 없습니다.', errors: ['오류라고 생각되면 문의해주세요.'] }) if (
['reported', 'blocked', 'archived'].includes(bot.state) &&
!checkUserFlag(userInfo?.flags, 'staff')
)
return ResponseWrapper(res, {
code: 403,
message: '해당 봇은 수정할 수 없습니다.',
errors: ['오류라고 생각되면 문의해주세요.'],
})
const csrfValidated = checkToken(req, res, req.body._csrf) const csrfValidated = checkToken(req, res, req.body._csrf)
if (!csrfValidated) return if (!csrfValidated) return
const captcha = await CaptchaVerify(req.body._captcha) const captcha = await CaptchaVerify(req.body._captcha)
if(!captcha) return ResponseWrapper(res, { code: 400, message: '캡챠 검증에 실패하였습니다.' }) if (!captcha) return ResponseWrapper(res, { code: 400, message: '캡챠 검증에 실패하였습니다.' })
if(req.body.name !== bot.name) return ResponseWrapper(res, { code: 400, message: '봇 이름을 입력해주세요.' }) if (req.body.name !== bot.name)
return ResponseWrapper(res, { code: 400, message: '봇 이름을 입력해주세요.' })
await remove.bot(bot.id) await remove.bot(bot.id)
await getMainGuild().members.cache.get(bot.id)?.kick('봇 삭제됨.') await getMainGuild().members.cache.get(bot.id)?.kick('봇 삭제됨.')
get.user.clear(user) get.user.clear(user)
await discordLog('BOT/DELETE', user, (new EmbedBuilder().setDescription(`${bot.name} - <@${bot.id}> ([${bot.id}](${KoreanbotsEndPoints.URL.bot(bot.id)}))`)), await discordLog(
'BOT/DELETE',
user,
new EmbedBuilder().setDescription(
`${bot.name} - <@${bot.id}> ([${bot.id}](${KoreanbotsEndPoints.URL.bot(bot.id)}))`
),
{ {
content: inspect(bot), content: inspect(bot),
format: 'js' format: 'js',
} }
) )
return ResponseWrapper(res, { code: 200, message: '성공적으로 삭제했습니다.' }) return ResponseWrapper(res, { code: 200, message: '성공적으로 삭제했습니다.' })
}) })
.patch(patchLimiter).patch(async (req: PatchApiRequest, res) => { .patch(patchLimiter)
.patch(async (req: PatchApiRequest, res) => {
const bot = await get.bot.load(req.query.id) const bot = await get.bot.load(req.query.id)
if(!bot) return ResponseWrapper(res, { code: 404, message: '존재하지 않는 봇입니다.' }) if (!bot) return ResponseWrapper(res, { code: 404, message: '존재하지 않는 봇입니다.' })
const user = await get.Authorization(req.cookies.token) const user = await get.Authorization(req.cookies.token)
if (!user) return ResponseWrapper(res, { code: 401 }) if (!user) return ResponseWrapper(res, { code: 401 })
const userInfo = await get.user.load(user) const userInfo = await get.user.load(user)
if(['reported', 'blocked', 'archived'].includes(bot.state) && !checkUserFlag(userInfo?.flags, 'staff')) return ResponseWrapper(res, { code: 403, message: '해당 봇은 수정할 수 없습니다.', errors: ['오류라고 생각되면 문의해주세요.'] }) if (
if(!(bot.owners as User[]).find(el => el.id === user) && !checkUserFlag(userInfo?.flags, 'staff')) return ResponseWrapper(res, { code: 403 }) ['reported', 'blocked', 'archived'].includes(bot.state) &&
!checkUserFlag(userInfo?.flags, 'staff')
)
return ResponseWrapper(res, {
code: 403,
message: '해당 봇은 수정할 수 없습니다.',
errors: ['오류라고 생각되면 문의해주세요.'],
})
if (
!(bot.owners as User[]).find((el) => el.id === user) &&
!checkUserFlag(userInfo?.flags, 'staff')
)
return ResponseWrapper(res, { code: 403 })
const csrfValidated = checkToken(req, res, req.body._csrf) const csrfValidated = checkToken(req, res, req.body._csrf)
if (!csrfValidated) return if (!csrfValidated) return
const validated = await ManageBotSchema.validate(req.body, { abortEarly: false }) const validated = await ManageBotSchema.validate(req.body, { abortEarly: false })
.then(el => el) .then((el) => el)
.catch(e => { .catch((e) => {
ResponseWrapper(res, { code: 400, errors: e.errors }) ResponseWrapper(res, { code: 400, errors: e.errors })
return null return null
}) })
if (!validated) return if (!validated) return
const result = await update.bot(req.query.id, validated) const result = await update.bot(req.query.id, validated)
if(result === 0) return ResponseWrapper(res, { code: 400 }) if (result === 0) return ResponseWrapper(res, { code: 400 })
else { else {
get.bot.clear(req.query.id) get.bot.clear(req.query.id)
const embed = new EmbedBuilder().setDescription(`${bot.name} - <@${bot.id}> ([${bot.id}](${KoreanbotsEndPoints.URL.bot(bot.id)}))`) const embed = new EmbedBuilder().setDescription(
const diffData = objectDiff( `${bot.name} - <@${bot.id}> ([${bot.id}](${KoreanbotsEndPoints.URL.bot(bot.id)}))`
{ prefix: bot.prefix, library: bot.lib, web: bot.web, git: bot.git, url: bot.url, discord: bot.discord, intro: bot.intro, category: JSON.stringify(bot.category) },
{ prefix: validated.prefix, library: validated.library, web: validated.website, git: validated.git, url: validated.url, discord: validated.discord, intro: validated.intro, category: JSON.stringify(validated.category) }
) )
diffData.forEach(d => { const diffData = objectDiff(
embed.addFields({name: d[0], value: makeDiscordCodeblock(diff(d[1][0] || '', d[1][1] || ''), 'diff')
})
})
await discordLog('BOT/EDIT', user, embed,
{ {
content: `--- 설명\n${diff(bot.desc, validated.desc, true)}`, prefix: bot.prefix,
format: 'diff' library: bot.lib,
web: bot.web,
git: bot.git,
url: bot.url,
discord: bot.discord,
intro: bot.intro,
category: JSON.stringify(bot.category),
},
{
prefix: validated.prefix,
library: validated.library,
web: validated.website,
git: validated.git,
url: validated.url,
discord: validated.discord,
intro: validated.intro,
category: JSON.stringify(validated.category),
} }
) )
diffData.forEach((d) => {
embed.addFields({
name: d[0],
value: makeDiscordCodeblock(diff(d[1][0] || '', d[1][1] || ''), 'diff'),
})
})
await discordLog('BOT/EDIT', user, embed, {
content: `--- 설명\n${diff(bot.desc, validated.desc, true)}`,
format: 'diff',
})
return ResponseWrapper(res, { code: 200 }) return ResponseWrapper(res, { code: 200 })
} }
}) })
interface GetApiRequest extends NextApiRequest { interface GetApiRequest extends NextApiRequest {
@ -195,7 +277,7 @@ interface PatchApiRequest extends GetApiRequest {
} }
interface DeleteApiRequest extends GetApiRequest { interface DeleteApiRequest extends GetApiRequest {
body: CsrfCaptcha & { name: string } | null body: (CsrfCaptcha & { name: string }) | null
} }
export default Bots export default Bots

View File

@ -11,40 +11,69 @@ import { discordLog } from '@utils/DiscordBot'
import { EmbedBuilder } from 'discord.js' import { EmbedBuilder } from 'discord.js'
import { KoreanbotsEndPoints } from '@utils/Constants' import { KoreanbotsEndPoints } from '@utils/Constants'
const BotOwners = RequestHandler() const BotOwners = RequestHandler().patch(async (req: PostApiRequest, res) => {
.patch(async (req: PostApiRequest, res) => { const user = await get.Authorization(req.cookies.token)
const user = await get.Authorization(req.cookies.token) if (!user) return ResponseWrapper(res, { code: 401 })
if (!user) return ResponseWrapper(res, { code: 401 }) const userinfo = await get.user.load(user)
const userinfo = await get.user.load(user) const bot = await get.bot.load(req.query.id)
const bot = await get.bot.load(req.query.id) if (!bot) return ResponseWrapper(res, { code: 404 })
if(!bot) return ResponseWrapper(res, { code: 404 }) if ((bot.owners as User[])[0].id !== user && !checkUserFlag(userinfo.flags, 'staff'))
if((bot.owners as User[])[0].id !== user && !checkUserFlag(userinfo.flags, 'staff')) return ResponseWrapper(res, { code: 403 }) return ResponseWrapper(res, { code: 403 })
if(['reported', 'blocked', 'archived'].includes(bot.state) && !checkUserFlag(userinfo.flags, 'staff')) return ResponseWrapper(res, { code: 403, message: '해당 봇은 수정할 수 없습니다.', errors: ['오류라고 생각되면 문의해주세요.'] }) if (
const validated = await EditBotOwnerSchema.validate(req.body, { abortEarly: false }) ['reported', 'blocked', 'archived'].includes(bot.state) &&
.then(el => el) !checkUserFlag(userinfo.flags, 'staff')
.catch(e => { )
ResponseWrapper(res, { code: 400, errors: e.errors }) return ResponseWrapper(res, {
return null code: 403,
}) message: '해당 봇은 수정할 수 없습니다.',
if(!validated) return errors: ['오류라고 생각되면 문의해주세요.'],
const csrfValidated = checkToken(req, res, validated._csrf) })
if (!csrfValidated) return const validated = await EditBotOwnerSchema.validate(req.body, { abortEarly: false })
const captcha = await CaptchaVerify(validated._captcha) .then((el) => el)
if(!captcha) return .catch((e) => {
const userFetched: User[] = await Promise.all(validated.owners.map((u: string) => get.user.load(u))) ResponseWrapper(res, { code: 400, errors: e.errors })
if(userFetched.indexOf(null) !== -1) return ResponseWrapper(res, { code: 400, message: '올바르지 않은 유저 ID를 포함하고 있습니다.' }) return null
if(userFetched.length > 1 && userFetched[0].id !== (bot.owners as User[])[0].id) return ResponseWrapper(res, { code: 400, errors: ['소유자를 이전할 때는 다른 관리자를 포함할 수 없습니다.'] }) })
await update.botOwners(bot.id, validated.owners) if (!validated) return
get.user.clear(user) const csrfValidated = checkToken(req, res, validated._csrf)
await discordLog('BOT/OWNERS', userinfo.id, (new EmbedBuilder().setDescription(`${bot.name} - <@${bot.id}> ([${bot.id}](${KoreanbotsEndPoints.URL.bot(bot.id)}))`)), null, makeDiscordCodeblock(diff(JSON.stringify(bot.owners.map(el => el.id)), JSON.stringify(validated.owners)), 'diff')) if (!csrfValidated) return
return ResponseWrapper(res, { code: 200 }) const captcha = await CaptchaVerify(validated._captcha)
}) if (!captcha) return
const userFetched: User[] = await Promise.all(
validated.owners.map((u: string) => get.user.load(u))
)
if (userFetched.indexOf(null) !== -1)
return ResponseWrapper(res, {
code: 400,
message: '올바르지 않은 유저 ID를 포함하고 있습니다.',
})
if (userFetched.length > 1 && userFetched[0].id !== (bot.owners as User[])[0].id)
return ResponseWrapper(res, {
code: 400,
errors: ['소유자를 이전할 때는 다른 관리자를 포함할 수 없습니다.'],
})
await update.botOwners(bot.id, validated.owners)
get.user.clear(user)
await discordLog(
'BOT/OWNERS',
userinfo.id,
new EmbedBuilder().setDescription(
`${bot.name} - <@${bot.id}> ([${bot.id}](${KoreanbotsEndPoints.URL.bot(bot.id)}))`
),
null,
makeDiscordCodeblock(
diff(JSON.stringify(bot.owners.map((el) => el.id)), JSON.stringify(validated.owners)),
'diff'
)
)
return ResponseWrapper(res, { code: 200 })
})
interface PostApiRequest extends NextApiRequest { interface PostApiRequest extends NextApiRequest {
query: { query: {
id: string id: string
}, }
body: EditBotOwner body: EditBotOwner
} }
export default BotOwners export default BotOwners

View File

@ -4,7 +4,7 @@ import rateLimit from 'express-rate-limit'
import { get } from '@utils/Query' import { get } from '@utils/Query'
import RequestHandler from '@utils/RequestHandler' import RequestHandler from '@utils/RequestHandler'
import ResponseWrapper from '@utils/ResponseWrapper' import ResponseWrapper from '@utils/ResponseWrapper'
import { ReportSchema, Report} from '@utils/Yup' import { ReportSchema, Report } from '@utils/Yup'
import { webhookClients } from '@utils/DiscordBot' import { webhookClients } from '@utils/DiscordBot'
import { checkToken } from '@utils/Csrf' import { checkToken } from '@utils/Csrf'
@ -18,36 +18,40 @@ const limiter = rateLimit({
skip: (_req, res) => { skip: (_req, res) => {
res.removeHeader('X-RateLimit-Global') res.removeHeader('X-RateLimit-Global')
return false return false
} },
}) })
const BotReport = RequestHandler().post(limiter) const BotReport = RequestHandler()
.post(limiter)
.post(async (req: PostApiRequest, res) => { .post(async (req: PostApiRequest, res) => {
const user = await get.Authorization(req.cookies.token) const user = await get.Authorization(req.cookies.token)
if(!user) return ResponseWrapper(res, { code: 401 }) if (!user) return ResponseWrapper(res, { code: 401 })
const bot = await get.bot.load(req.query.id) const bot = await get.bot.load(req.query.id)
if(!bot) return ResponseWrapper(res, { code: 404, message: '존재하지 않는 봇입니다.' }) if (!bot) return ResponseWrapper(res, { code: 404, message: '존재하지 않는 봇입니다.' })
const csrfValidated = checkToken(req, res, req.body._csrf) const csrfValidated = checkToken(req, res, req.body._csrf)
if (!csrfValidated) return if (!csrfValidated) return
if(!req.body) return ResponseWrapper(res, { code: 400 }) if (!req.body) return ResponseWrapper(res, { code: 400 })
const validated: Report = await ReportSchema.validate(req.body, { abortEarly: false }) const validated: Report = await ReportSchema.validate(req.body, { abortEarly: false })
.then(el => el) .then((el) => el)
.catch(e => { .catch((e) => {
ResponseWrapper(res, { code: 400, errors: e.errors }) ResponseWrapper(res, { code: 400, errors: e.errors })
return null return null
}) })
if(!validated) return if (!validated) return
await webhookClients.internal.reportChannel.send({ threadName: `봇-${bot.id}`, content: `Reported by <@${user}> (${user})\nReported **${bot.name}** <@${bot.id}> (${bot.id})\nCategory ${req.body.category}\nDesc\n\`\`\`${req.body.description}\`\`\``, allowedMentions: { parse: ['users'] }}) await webhookClients.internal.reportChannel.send({
threadName: `봇-${bot.id}`,
content: `Reported by <@${user}> (${user})\nReported **${bot.name}** <@${bot.id}> (${bot.id})\nCategory ${req.body.category}\nDesc\n\`\`\`${req.body.description}\`\`\``,
allowedMentions: { parse: ['users'] },
})
return ResponseWrapper(res, { code: 200, message: '성공적으로 처리되었습니다.' }) return ResponseWrapper(res, { code: 200, message: '성공적으로 처리되었습니다.' })
}) })
interface PostApiRequest extends NextApiRequest { interface PostApiRequest extends NextApiRequest {
body: Report | null body: Report | null
query: { query: {
id: string id: string
} }
} }
export default BotReport export default BotReport

View File

@ -21,9 +21,9 @@ const limiter = rateLimit({
keyGenerator: (req) => req.headers.authorization, keyGenerator: (req) => req.headers.authorization,
skip: (req, res) => { skip: (req, res) => {
res.removeHeader('X-RateLimit-Global') res.removeHeader('X-RateLimit-Global')
if(!req.headers.authorization) return true if (!req.headers.authorization) return true
else return false else return false
} },
}) })
const patchLimiter = rateLimit({ const patchLimiter = rateLimit({
@ -36,27 +36,36 @@ const patchLimiter = rateLimit({
skip: (_req, res) => { skip: (_req, res) => {
res.removeHeader('X-RateLimit-Global') res.removeHeader('X-RateLimit-Global')
return false return false
} },
}) })
const BotStats = RequestHandler().post(limiter) const BotStats = RequestHandler()
.post(limiter)
.post(async (req: PostApiRequest, res) => { .post(async (req: PostApiRequest, res) => {
const bot = await get.BotAuthorization(req.headers.authorization) const bot = await get.BotAuthorization(req.headers.authorization)
if(!bot) return ResponseWrapper(res, { code: 401 }) if (!bot) return ResponseWrapper(res, { code: 401 })
if(!req.body) return ResponseWrapper(res, { code: 400 }) if (!req.body) return ResponseWrapper(res, { code: 400 })
const validated: BotStatUpdate = await BotStatUpdateSchema.validate(req.body, { abortEarly: false }) const validated: BotStatUpdate = await BotStatUpdateSchema.validate(req.body, {
.then(el => el) abortEarly: false,
.catch(e => { })
.then((el) => el)
.catch((e) => {
ResponseWrapper(res, { code: 400, errors: e.errors }) ResponseWrapper(res, { code: 400, errors: e.errors })
return null return null
}) })
if(!validated) return if (!validated) return
const botInfo = await get.bot.load(req.query.id) const botInfo = await get.bot.load(req.query.id)
if(!botInfo) return ResponseWrapper(res, { code: 404, message: '존재하지 않는 봇입니다.' }) if (!botInfo) return ResponseWrapper(res, { code: 404, message: '존재하지 않는 봇입니다.' })
if(botInfo.id !== bot) return ResponseWrapper(res, { code: 403 }) if (botInfo.id !== bot) return ResponseWrapper(res, { code: 403 })
const d = await update.updateServer(botInfo.id, validated.servers, validated.shards) const d = await update.updateServer(botInfo.id, validated.servers, validated.shards)
if(d===1 || d===2) return ResponseWrapper(res, { code: 403, message: `서버 수를 ${[null, '1만', '100만'][d]} 이상으로 설정하실 수 없습니다. 문의해주세요.` }) if (d === 1 || d === 2)
return ResponseWrapper(res, {
code: 403,
message: `서버 수를 ${
[null, '1만', '100만'][d]
} . .`,
})
get.bot.clear(req.query.id) get.bot.clear(req.query.id)
if (validated.servers && botInfo.servers !== validated.servers) { if (validated.servers && botInfo.servers !== validated.servers) {
sendWebhook(botInfo, { sendWebhook(botInfo, {
@ -67,37 +76,54 @@ const BotStats = RequestHandler().post(limiter)
before: botInfo.servers, before: botInfo.servers,
after: validated.servers, after: validated.servers,
}, },
timestamp: Date.now() timestamp: Date.now(),
}) })
} }
await webhookClients.internal.statsLog.send({ await webhookClients.internal.statsLog.send({
content: `[BOT/STATS] <@${botInfo.id}> (${botInfo.id})\n${makeDiscordCodeblock(`${botInfo.servers > validated.servers ? '-' : '+'} ${botInfo.servers} -> ${validated.servers} (${botInfo.servers > validated.servers ? '▼' : '▲'}${Math.abs(validated.servers - botInfo.servers)})`, 'diff')}`, content: `[BOT/STATS] <@${botInfo.id}> (${botInfo.id})\n${makeDiscordCodeblock(
embeds: [new EmbedBuilder().setDescription(`${botInfo.name} - <@${botInfo.id}> ([${botInfo.id}](${KoreanbotsEndPoints.URL.bot(botInfo.id)}))`)] `${botInfo.servers > validated.servers ? '-' : '+'} ${botInfo.servers} -> ${
validated.servers
} (${botInfo.servers > validated.servers ? '▼' : '▲'}${Math.abs(
validated.servers - botInfo.servers
)})`,
'diff'
)}`,
embeds: [
new EmbedBuilder().setDescription(
`${botInfo.name} - <@${botInfo.id}> ([${botInfo.id}](${KoreanbotsEndPoints.URL.bot(
botInfo.id
)}))`
),
],
}) })
return ResponseWrapper(res, { code: 200, message: '성공적으로 업데이트 했습니다.'}) return ResponseWrapper(res, { code: 200, message: '성공적으로 업데이트 했습니다.' })
}) })
.patch(patchLimiter).patch(async (req: ApiRequest, res) => { .patch(patchLimiter)
.patch(async (req: ApiRequest, res) => {
const user = await get.Authorization(req.cookies.token) const user = await get.Authorization(req.cookies.token)
if (!user) return ResponseWrapper(res, { code: 401 }) if (!user) return ResponseWrapper(res, { code: 401 })
const userinfo = await get.user.load(user) const userinfo = await get.user.load(user)
const bot = await get.bot.load(req.query.id) const bot = await get.bot.load(req.query.id)
if(!bot) return ResponseWrapper(res, { code: 404 }) if (!bot) return ResponseWrapper(res, { code: 404 })
if(!(bot.owners as User[]).find(el => el.id === user) && !checkUserFlag(userinfo.flags, 'staff')) return ResponseWrapper(res, { code: 403 }) if (
!(bot.owners as User[]).find((el) => el.id === user) &&
!checkUserFlag(userinfo.flags, 'staff')
)
return ResponseWrapper(res, { code: 403 })
get.bot.clear(req.query.id) get.bot.clear(req.query.id)
return ResponseWrapper(res, { code: 200 }) return ResponseWrapper(res, { code: 200 })
}) })
interface ApiRequest extends NextApiRequest { interface ApiRequest extends NextApiRequest {
query: { query: {
id: string id: string
} }
} }
interface PostApiRequest extends ApiRequest { interface PostApiRequest extends ApiRequest {
query: { query: {
id: string id: string
} }
body: BotStatUpdate body: BotStatUpdate
} }
export default BotStats export default BotStats

View File

@ -12,29 +12,37 @@ import { WebhookType } from '@types'
const BotVote = RequestHandler() const BotVote = RequestHandler()
.get(async (req: GetApiRequest, res) => { .get(async (req: GetApiRequest, res) => {
const bot = await get.BotAuthorization(req.headers.authorization) const bot = await get.BotAuthorization(req.headers.authorization)
if(!bot) return ResponseWrapper(res, { code: 401 }) if (!bot) return ResponseWrapper(res, { code: 401 })
if(req.query.id !== bot) return ResponseWrapper(res, { code: 403 }) if (req.query.id !== bot) return ResponseWrapper(res, { code: 403 })
const userID = await Yup.string().required().label('userID').validate(req.query.userID).then(el => el).catch(e => { const userID = await Yup.string()
ResponseWrapper(res, { code: 400, errors: e.errors }) .required()
return null .label('userID')
}) .validate(req.query.userID)
if(!userID) return ResponseWrapper(res, { code: 400 }) .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.botVote(userID, bot) const result = await get.botVote(userID, bot)
return ResponseWrapper(res, { code: 200, data: { voted: +new Date() < result + VOTE_COOLDOWN, lastVote: result } }) return ResponseWrapper(res, {
code: 200,
data: { voted: +new Date() < result + VOTE_COOLDOWN, lastVote: result },
})
}) })
.post(async (req: PostApiRequest, res) => { .post(async (req: PostApiRequest, res) => {
const user = await get.Authorization(req.cookies.token) const user = await get.Authorization(req.cookies.token)
if(!user) return ResponseWrapper(res, { code: 401 }) if (!user) return ResponseWrapper(res, { code: 401 })
const bot = await get.bot.load(req.query.id) const bot = await get.bot.load(req.query.id)
if (!bot) return ResponseWrapper(res, { code: 404, message: '존재하지 않는 봇입니다.' }) if (!bot) return ResponseWrapper(res, { code: 404, message: '존재하지 않는 봇입니다.' })
const csrfValidated = checkToken(req, res, req.body._csrf) const csrfValidated = checkToken(req, res, req.body._csrf)
if (!csrfValidated) return if (!csrfValidated) return
const captcha = await CaptchaVerify(req.body._captcha) const captcha = await CaptchaVerify(req.body._captcha)
if(!captcha) return ResponseWrapper(res, { code: 400, message: '캡챠 검증에 실패하였습니다.' }) if (!captcha) return ResponseWrapper(res, { code: 400, message: '캡챠 검증에 실패하였습니다.' })
const vote = await put.voteBot(user, bot.id) const vote = await put.voteBot(user, bot.id)
if(vote === null) return ResponseWrapper(res, { code: 401 }) if (vote === null) return ResponseWrapper(res, { code: 401 })
else if(vote === true) { else if (vote === true) {
sendWebhook(bot, { sendWebhook(bot, {
type: 'bot', type: 'bot',
data: { data: {
@ -42,19 +50,18 @@ const BotVote = RequestHandler()
type: WebhookType.HeartChange, type: WebhookType.HeartChange,
before: bot.votes, before: bot.votes,
after: bot.votes + 1, after: bot.votes + 1,
userId: user userId: user,
}, },
timestamp: Date.now() timestamp: Date.now(),
}) })
return ResponseWrapper(res, { code: 200 }) return ResponseWrapper(res, { code: 200 })
} } else return ResponseWrapper(res, { code: 429, data: { retryAfter: vote } })
else return ResponseWrapper(res, { code: 429, data: { retryAfter: vote } })
}) })
interface ApiRequest extends NextApiRequest { interface ApiRequest extends NextApiRequest {
query: { query: {
id: string id: string
} }
} }
interface GetApiRequest extends ApiRequest { interface GetApiRequest extends ApiRequest {
@ -65,8 +72,8 @@ interface GetApiRequest extends ApiRequest {
} }
interface PostApiRequest extends ApiRequest { interface PostApiRequest extends ApiRequest {
body: { body: {
_captcha: string _captcha: string
_csrf: string _csrf: string
} }
} }
export default BotVote export default BotVote

View File

@ -9,4 +9,4 @@ const NewList = RequestHandler().get(async (_req, res) => {
return ResponseWrapper<List<Bot>>(res, { code: 200, data: result }) return ResponseWrapper<List<Bot>>(res, { code: 200, data: result })
}) })
export default NewList export default NewList

View File

@ -6,14 +6,20 @@ import { Bot, List } from '@types'
import Yup from '@utils/Yup' import Yup from '@utils/Yup'
const VotesList = RequestHandler().get(async (req, res) => { const VotesList = RequestHandler().get(async (req, res) => {
const page = await Yup.number().positive().integer().notRequired().default(1).label('페이지').validate(req.query.page) const page = await Yup.number()
.then(el => el) .positive()
.catch(e => { .integer()
.notRequired()
.default(1)
.label('페이지')
.validate(req.query.page)
.then((el) => el)
.catch((e) => {
ResponseWrapper(res, { code: 400, errors: e.errors }) ResponseWrapper(res, { code: 400, errors: e.errors })
}) })
if(!page) return if (!page) return
const result = await get.list.votes.load(page) const result = await get.list.votes.load(page)
return ResponseWrapper<List<Bot>>(res, { code: 200, data: result }) return ResponseWrapper<List<Bot>>(res, { code: 200, data: result })
}) })
export default VotesList export default VotesList

View File

@ -8,36 +8,47 @@ import { get, update } from '@utils/Query'
import { DiscordBot, webhookClients } from '@utils/DiscordBot' import { DiscordBot, webhookClients } from '@utils/DiscordBot'
import { KoreanbotsEndPoints } from '@utils/Constants' import { KoreanbotsEndPoints } from '@utils/Constants'
const ApproveBotSubmit = RequestHandler() const ApproveBotSubmit = RequestHandler().post(async (req: ApiRequest, res) => {
.post(async (req: ApiRequest, res) => { const bot = await get.BotAuthorization(req.headers.authorization)
const bot = await get.BotAuthorization(req.headers.authorization) if (bot !== DiscordBot.user.id) return ResponseWrapper(res, { code: 403 })
if(bot !== DiscordBot.user.id) return ResponseWrapper(res, { code: 403 }) const submit = await get.botSubmit.load(
const submit = await get.botSubmit.load(JSON.stringify({ id: req.query.id, date: req.query.date })) JSON.stringify({ id: req.query.id, date: req.query.date })
if(!submit) return ResponseWrapper(res, { code: 404 }) )
if(submit.state !== 0) return ResponseWrapper(res, { code: 400, message: '대기 중이지 않은 아이디입니다.' }) if (!submit) return ResponseWrapper(res, { code: 404 })
const result = await update.approveBotSubmission(submit.id, submit.date) if (submit.state !== 0)
if(!result) return ResponseWrapper(res, { code: 400 }) return ResponseWrapper(res, { code: 400, message: '대기 중이지 않은 아이디입니다.' })
get.botSubmit.clear(JSON.stringify({ id: req.query.id, date: req.query.date })) const result = await update.approveBotSubmission(submit.id, submit.date)
get.bot.clear(req.query.id) if (!result) return ResponseWrapper(res, { code: 400 })
const embed = new EmbedBuilder().setTitle('승인').setColor(Colors.Green).setDescription(`[${submit.id}/${submit.date}](${KoreanbotsEndPoints.URL.submittedBot(submit.id, submit.date)})`).setTimestamp() get.botSubmit.clear(JSON.stringify({ id: req.query.id, date: req.query.date }))
if(req.body.reviewer) embed.addFields({name: '📃 정보', value: `심사자: ${req.body.reviewer}`}) get.bot.clear(req.query.id)
await webhookClients.internal.reviewLog.send({embeds: [embed]}) const embed = new EmbedBuilder()
tracer.trace('botSubmits.approve', span => { .setTitle('승인')
span.setTag('id', submit.id) .setColor(Colors.Green)
span.setTag('date', submit.date) .setDescription(
span.setTag('reviewer', req.body.reviewer) `[${submit.id}/${submit.date}](${KoreanbotsEndPoints.URL.submittedBot(
}) submit.id,
return ResponseWrapper(res, { code: 200 }) submit.date
)})`
)
.setTimestamp()
if (req.body.reviewer) embed.addFields({ name: '📃 정보', value: `심사자: ${req.body.reviewer}` })
await webhookClients.internal.reviewLog.send({ embeds: [embed] })
tracer.trace('botSubmits.approve', (span) => {
span.setTag('id', submit.id)
span.setTag('date', submit.date)
span.setTag('reviewer', req.body.reviewer)
}) })
return ResponseWrapper(res, { code: 200 })
})
interface ApiRequest extends NextApiRequest { interface ApiRequest extends NextApiRequest {
query: { query: {
id: string id: string
date: string date: string
} }
body: { body: {
reviewer?: string reviewer?: string
} }
} }
export default ApproveBotSubmit export default ApproveBotSubmit

View File

@ -8,40 +8,71 @@ import { get, update } from '@utils/Query'
import { DiscordBot, webhookClients } from '@utils/DiscordBot' import { DiscordBot, webhookClients } from '@utils/DiscordBot'
import { BotSubmissionDenyReasonPresetsName, KoreanbotsEndPoints } from '@utils/Constants' import { BotSubmissionDenyReasonPresetsName, KoreanbotsEndPoints } from '@utils/Constants'
const DenyBotSubmit = RequestHandler() const DenyBotSubmit = RequestHandler().post(async (req: ApiRequest, res) => {
.post(async (req: ApiRequest, res) => { const bot = await get.BotAuthorization(req.headers.authorization)
const bot = await get.BotAuthorization(req.headers.authorization) if (bot !== DiscordBot.user.id) return ResponseWrapper(res, { code: 403 })
if(bot !== DiscordBot.user.id) return ResponseWrapper(res, { code: 403 }) const submit = await get.botSubmit.load(
const submit = await get.botSubmit.load(JSON.stringify({ id: req.query.id, date: req.query.date })) JSON.stringify({ id: req.query.id, date: req.query.date })
if(!submit) return ResponseWrapper(res, { code: 404 }) )
if(submit.state !== 0) return ResponseWrapper(res, { code: 400, message: '대기 중이지 않은 아이디입니다.' }) if (!submit) return ResponseWrapper(res, { code: 404 })
await update.denyBotSubmission(submit.id, submit.date, req.body.reason) if (submit.state !== 0)
get.botSubmit.clear(JSON.stringify({ id: req.query.id, date: req.query.date })) return ResponseWrapper(res, { code: 400, message: '대기 중이지 않은 아이디입니다.' })
const embed = new EmbedBuilder().setTitle('거부').setColor(Colors.Red).setDescription(`[${submit.id}/${submit.date}](${KoreanbotsEndPoints.URL.submittedBot(submit.id, submit.date)})`).setTimestamp() await update.denyBotSubmission(submit.id, submit.date, req.body.reason)
if(req.body.reviewer || req.body.reason) embed.addFields({name: '📃 정보', value: `${req.body.reason ? `사유: ${BotSubmissionDenyReasonPresetsName[req.body.reason] || req.body.reason}\n`: ''}${req.body.reviewer ? `심사자: ${req.body.reviewer}` : ''}`}) get.botSubmit.clear(JSON.stringify({ id: req.query.id, date: req.query.date }))
await webhookClients.internal.reviewLog.send({embeds: [embed]}) const embed = new EmbedBuilder()
const openEmbed = new EmbedBuilder().setTitle('거부').setColor(Colors.Red).setDescription(`<@${submit.id}> (${submit.id})`).setTimestamp() .setTitle('거부')
if(req.body.reason) openEmbed.addFields({name: '📃 사유', value: `${req.body.reason ? `${BotSubmissionDenyReasonPresetsName[req.body.reason] || req.body.reason}\n`: '없음'}`}) .setColor(Colors.Red)
await webhookClients.internal.openReviewLog.send({embeds: [openEmbed]}) .setDescription(
tracer.trace('botSubmits.deny', span => { `[${submit.id}/${submit.date}](${KoreanbotsEndPoints.URL.submittedBot(
span.setTag('id', submit.id) submit.id,
span.setTag('date', submit.date) submit.date
span.setTag('reviewer', req.body.reviewer) )})`
span.setTag('reason', BotSubmissionDenyReasonPresetsName[req.body.reason] || 'OTHER') )
span.setTag('_raw_reason', req.body.reason) .setTimestamp()
if (req.body.reviewer || req.body.reason)
embed.addFields({
name: '📃 정보',
value: `${
req.body.reason
? `사유: ${BotSubmissionDenyReasonPresetsName[req.body.reason] || req.body.reason}\n`
: ''
}${req.body.reviewer ? `심사자: ${req.body.reviewer}` : ''}`,
}) })
return ResponseWrapper(res, { code: 200 }) await webhookClients.internal.reviewLog.send({ embeds: [embed] })
const openEmbed = new EmbedBuilder()
.setTitle('거부')
.setColor(Colors.Red)
.setDescription(`<@${submit.id}> (${submit.id})`)
.setTimestamp()
if (req.body.reason)
openEmbed.addFields({
name: '📃 사유',
value: `${
req.body.reason
? `${BotSubmissionDenyReasonPresetsName[req.body.reason] || req.body.reason}\n`
: '없음'
}`,
})
await webhookClients.internal.openReviewLog.send({ embeds: [openEmbed] })
tracer.trace('botSubmits.deny', (span) => {
span.setTag('id', submit.id)
span.setTag('date', submit.date)
span.setTag('reviewer', req.body.reviewer)
span.setTag('reason', BotSubmissionDenyReasonPresetsName[req.body.reason] || 'OTHER')
span.setTag('_raw_reason', req.body.reason)
}) })
return ResponseWrapper(res, { code: 200 })
})
interface ApiRequest extends NextApiRequest { interface ApiRequest extends NextApiRequest {
query: { query: {
id: string id: string
date: string date: string
} }
body: { body: {
reason?: string reason?: string
reviewer: string reviewer: string
} }
} }
export default DenyBotSubmit export default DenyBotSubmit

View File

@ -5,20 +5,21 @@ import ResponseWrapper from '@utils/ResponseWrapper'
import { get } from '@utils/Query' import { get } from '@utils/Query'
import { DiscordBot } from '@utils/DiscordBot' import { DiscordBot } from '@utils/DiscordBot'
const BotSubmit = RequestHandler() const BotSubmit = RequestHandler().get(async (req: ApiRequest, res) => {
.get(async (req: ApiRequest, res) => { const bot = await get.BotAuthorization(req.headers.authorization)
const bot = await get.BotAuthorization(req.headers.authorization) if (bot !== DiscordBot.user.id) return ResponseWrapper(res, { code: 403 })
if(bot !== DiscordBot.user.id) return ResponseWrapper(res, { code: 403 }) const submit = await get.botSubmit.load(
const submit = await get.botSubmit.load(JSON.stringify({ id: req.query.id, date: req.query.date })) JSON.stringify({ id: req.query.id, date: req.query.date })
if(!submit) return ResponseWrapper(res, { code: 404 }) )
return ResponseWrapper(res, { code: 200, data: submit }) if (!submit) return ResponseWrapper(res, { code: 404 })
}) return ResponseWrapper(res, { code: 200, data: submit })
})
interface ApiRequest extends NextApiRequest { interface ApiRequest extends NextApiRequest {
query: { query: {
id: string id: string
date: string date: string
} }
} }
export default BotSubmit export default BotSubmit

View File

@ -5,17 +5,16 @@ import ResponseWrapper from '@utils/ResponseWrapper'
import { get } from '@utils/Query' import { get } from '@utils/Query'
import { DiscordBot } from '@utils/DiscordBot' import { DiscordBot } from '@utils/DiscordBot'
const BotSubmit = RequestHandler() const BotSubmit = RequestHandler().get(async (req: ApiRequest, res) => {
.get(async (req: ApiRequest, res) => { const bot = await get.BotAuthorization(req.headers.authorization)
const bot = await get.BotAuthorization(req.headers.authorization) if (bot !== DiscordBot.user.id) return ResponseWrapper(res, { code: 403 })
if(bot !== DiscordBot.user.id) return ResponseWrapper(res, { code: 403 }) return ResponseWrapper(res, { code: 200, data: await get.botSubmitHistory(req.query.id) })
return ResponseWrapper(res, { code: 200, data: await get.botSubmitHistory(req.query.id) }) })
})
interface ApiRequest extends NextApiRequest { interface ApiRequest extends NextApiRequest {
query: { query: {
id: string id: string
} }
} }
export default BotSubmit export default BotSubmit

View File

@ -5,19 +5,18 @@ import ResponseWrapper from '@utils/ResponseWrapper'
import { get } from '@utils/Query' import { get } from '@utils/Query'
import { DiscordBot } from '@utils/DiscordBot' import { DiscordBot } from '@utils/DiscordBot'
const BotSubmit = RequestHandler() const BotSubmit = RequestHandler().get(async (req: ApiRequest, res) => {
.get(async (req: ApiRequest, res) => { const bot = await get.BotAuthorization(req.headers.authorization)
const bot = await get.BotAuthorization(req.headers.authorization) if (bot !== DiscordBot.user.id) return ResponseWrapper(res, { code: 403 })
if(bot !== DiscordBot.user.id) return ResponseWrapper(res, { code: 403 }) const submits = await get.botSubmitList()
const submits = await get.botSubmitList() const submit = submits.find((el, n) => el.id === req.query.id || n + 1 === Number(req.query.id))
const submit = submits.find((el, n) => el.id === req.query.id || n+1 === Number(req.query.id)) if (!submit) return ResponseWrapper(res, { code: 404 })
if(!submit) return ResponseWrapper(res, { code: 404 }) return ResponseWrapper(res, { code: 200, data: submit })
return ResponseWrapper(res, { code: 200, data: submit }) })
})
interface ApiRequest extends NextApiRequest { interface ApiRequest extends NextApiRequest {
query: { query: {
id: string id: string
} }
} }
export default BotSubmit export default BotSubmit

View File

@ -3,12 +3,11 @@ import ResponseWrapper from '@utils/ResponseWrapper'
import { get } from '@utils/Query' import { get } from '@utils/Query'
import { DiscordBot } from '@utils/DiscordBot' import { DiscordBot } from '@utils/DiscordBot'
const BotSubmits = RequestHandler() const BotSubmits = RequestHandler().get(async (req, res) => {
.get(async (req, res) => { const bot = await get.BotAuthorization(req.headers.authorization)
const bot = await get.BotAuthorization(req.headers.authorization) if (bot !== DiscordBot.user.id) return ResponseWrapper(res, { code: 403 })
if(bot !== DiscordBot.user.id) return ResponseWrapper(res, { code: 403 }) const submits = await get.botSubmitList()
const submits = await get.botSubmitList() return ResponseWrapper(res, { code: 200, data: submits })
return ResponseWrapper(res, { code: 200, data: submits }) })
})
export default BotSubmits export default BotSubmits

View File

@ -1,9 +1,8 @@
import RequestHandler from '@utils/RequestHandler' import RequestHandler from '@utils/RequestHandler'
import ResponseWrapper from '@utils/ResponseWrapper' import ResponseWrapper from '@utils/ResponseWrapper'
const BotSubmits = RequestHandler() const BotSubmits = RequestHandler().get(async (_req, res) => {
.get(async (_req, res) => { return ResponseWrapper(res, { code: 403, message: 'Private API' })
return ResponseWrapper(res, { code: 403, message: 'Private API' }) })
})
export default BotSubmits export default BotSubmits

View File

@ -1,4 +1,3 @@
import { NextApiHandler } from 'next' import { NextApiHandler } from 'next'
import { getMainGuild } from '@utils/DiscordBot' import { getMainGuild } from '@utils/DiscordBot'
import RequestHandler from '@utils/RequestHandler' import RequestHandler from '@utils/RequestHandler'

View File

@ -9,8 +9,8 @@ import { Bot, Server, List } from '@types'
const Search = RequestHandler().get(async (req: ApiRequest, res) => { const Search = RequestHandler().get(async (req: ApiRequest, res) => {
const validated = await SearchQuerySchema.validate({ q: req.query.q || req.query.query, page: 1 }) const validated = await SearchQuerySchema.validate({ q: req.query.q || req.query.query, page: 1 })
.then(el => el) .then((el) => el)
.catch(e => { .catch((e) => {
ResponseWrapper(res, { code: 400, errors: e.errors }) ResponseWrapper(res, { code: 400, errors: e.errors })
}) })
if (!validated) return if (!validated) return
@ -27,7 +27,10 @@ const Search = RequestHandler().get(async (req: ApiRequest, res) => {
} catch { } catch {
return ResponseWrapper(res, { code: 400, message: '검색 문법이 잘못되었습니다.' }) return ResponseWrapper(res, { code: 400, message: '검색 문법이 잘못되었습니다.' })
} }
return ResponseWrapper<{ bots: Bot[], servers: Server[] }>(res, { code: 200, data: { bots: botResult?.data || [], servers: serverResult?.data || [] } }) return ResponseWrapper<{ bots: Bot[]; servers: Server[] }>(res, {
code: 200,
data: { bots: botResult?.data || [], servers: serverResult?.data || [] },
})
}) })
interface ApiRequest extends NextApiRequest { interface ApiRequest extends NextApiRequest {

View File

@ -8,9 +8,12 @@ import { SearchQuerySchema } from '@utils/Yup'
import { Bot, List } from '@types' import { Bot, List } from '@types'
const SearchBots = RequestHandler().get(async (req: ApiRequest, res: NextApiResponse) => { 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 }) const validated = await SearchQuerySchema.validate({
.then(el => el) q: req.query.q || req.query.query,
.catch(e => { page: req.query.page,
})
.then((el) => el)
.catch((e) => {
ResponseWrapper(res, { code: 400, errors: e.errors }) ResponseWrapper(res, { code: 400, errors: e.errors })
}) })
if (!validated) return if (!validated) return

View File

@ -8,9 +8,12 @@ import { SearchQuerySchema } from '@utils/Yup'
import { Server, List } from '@types' import { Server, List } from '@types'
const SearchServers = RequestHandler().get(async (req: ApiRequest, res: NextApiResponse) => { 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 }) const validated = await SearchQuerySchema.validate({
.then(el => el) q: req.query.q || req.query.query,
.catch(e => { page: req.query.page,
})
.then((el) => el)
.catch((e) => {
ResponseWrapper(res, { code: 400, errors: e.errors }) ResponseWrapper(res, { code: 400, errors: e.errors })
}) })
if (!validated) return if (!validated) return

View File

@ -5,9 +5,22 @@ import { EmbedBuilder, RESTJSONErrorCodes } from 'discord.js'
import { CaptchaVerify, get, put, remove, update } from '@utils/Query' import { CaptchaVerify, get, put, remove, update } from '@utils/Query'
import ResponseWrapper from '@utils/ResponseWrapper' import ResponseWrapper from '@utils/ResponseWrapper'
import { checkToken } from '@utils/Csrf' import { checkToken } from '@utils/Csrf'
import { AddServerSubmitSchema, AddServerSubmit, CsrfCaptcha, ManageServerSchema, ManageServer } from '@utils/Yup' import {
AddServerSubmitSchema,
AddServerSubmit,
CsrfCaptcha,
ManageServerSchema,
ManageServer,
} from '@utils/Yup'
import RequestHandler from '@utils/RequestHandler' import RequestHandler from '@utils/RequestHandler'
import { checkUserFlag, diff, inspect, makeDiscordCodeblock, objectDiff, serialize } from '@utils/Tools' import {
checkUserFlag,
diff,
inspect,
makeDiscordCodeblock,
objectDiff,
serialize,
} from '@utils/Tools'
import { DiscordBot, discordLog } from '@utils/DiscordBot' import { DiscordBot, discordLog } from '@utils/DiscordBot'
import { KoreanbotsEndPoints } from '@utils/Constants' import { KoreanbotsEndPoints } from '@utils/Constants'
@ -19,14 +32,14 @@ const patchLimiter = rateLimit({
skip: (_req, res) => { skip: (_req, res) => {
res.removeHeader('X-RateLimit-Global') res.removeHeader('X-RateLimit-Global')
return false return false
} },
}) })
const Servers = RequestHandler() const Servers = RequestHandler()
.get(async (req: GetApiRequest, res) => { .get(async (req: GetApiRequest, res) => {
const server = await get.server.load(req.query.id) const server = await get.server.load(req.query.id)
if (!server) return ResponseWrapper(res, { code: 404, message: '존재하지 않는 서버 입니다.' }) if (!server) return ResponseWrapper(res, { code: 404, message: '존재하지 않는 서버 입니다.' })
else { else {
return ResponseWrapper(res, { code: 200, data: server }) return ResponseWrapper(res, { code: 200, data: server })
} }
}) })
.post(async (req: PostApiRequest, res) => { .post(async (req: PostApiRequest, res) => {
@ -36,20 +49,20 @@ const Servers = RequestHandler()
if (!csrfValidated) return if (!csrfValidated) return
const validated = await AddServerSubmitSchema.validate(req.body, { abortEarly: false }) const validated = await AddServerSubmitSchema.validate(req.body, { abortEarly: false })
.then(el => el) .then((el) => el)
.catch(e => { .catch((e) => {
ResponseWrapper(res, { code: 400, errors: e.errors }) ResponseWrapper(res, { code: 400, errors: e.errors })
return null return null
}) })
if (!validated) return if (!validated) return
const captcha = await CaptchaVerify(validated._captcha) const captcha = await CaptchaVerify(validated._captcha)
if(!captcha) return ResponseWrapper(res, { code: 400, message: '캡챠 검증에 실패하였습니다.' }) if (!captcha) return ResponseWrapper(res, { code: 400, message: '캡챠 검증에 실패하였습니다.' })
const result = await put.submitServer(user, req.query.id, validated) const result = await put.submitServer(user, req.query.id, validated)
if (result === 1) if (result === 1)
return ResponseWrapper(res, { return ResponseWrapper(res, {
code: 400, code: 400,
message: '이미 등록된 서버 입니다.' message: '이미 등록된 서버 입니다.',
}) })
else if (result === 2) else if (result === 2)
return ResponseWrapper(res, { return ResponseWrapper(res, {
@ -57,7 +70,7 @@ const Servers = RequestHandler()
message: '봇이 초대되지 않았습니다.', message: '봇이 초대되지 않았습니다.',
errors: [ errors: [
'서버에 봇이 초대되지 않았습니다.', '서버에 봇이 초대되지 않았습니다.',
'이미 봇을 초대하셨다면, 잠시 후 다시 시도해주세요.' '이미 봇을 초대하셨다면, 잠시 후 다시 시도해주세요.',
], ],
}) })
else if (result === 3) else if (result === 3)
@ -66,7 +79,7 @@ const Servers = RequestHandler()
message: '서버의 관리자가 아닙니다.', message: '서버의 관리자가 아닙니다.',
errors: [ errors: [
'해당 서버를 등록할 권한이 없습니다.', '해당 서버를 등록할 권한이 없습니다.',
'서버에서 관리자 권한이 있으신지 확인해주세요.' '서버에서 관리자 권한이 있으신지 확인해주세요.',
], ],
}) })
else if (result === 4) else if (result === 4)
@ -75,14 +88,21 @@ const Servers = RequestHandler()
message: '올바르지 않은 초대 코드 입니다.', message: '올바르지 않은 초대 코드 입니다.',
errors: [ errors: [
'올바른 초대코드를 입력하셨는지 확인해주세요.', '올바른 초대코드를 입력하셨는지 확인해주세요.',
'만료되지 않는 초대코드인지 확인해주세요.' '만료되지 않는 초대코드인지 확인해주세요.',
], ],
}) })
get.user.clear(user) get.user.clear(user)
await discordLog('SERVER/SUBMIT', user, new EmbedBuilder().setDescription(`[${req.query.id}](${KoreanbotsEndPoints.URL.server(req.query.id)})`), { await discordLog(
content: inspect(serialize(validated)), 'SERVER/SUBMIT',
format: 'js' user,
}) new EmbedBuilder().setDescription(
`[${req.query.id}](${KoreanbotsEndPoints.URL.server(req.query.id)})`
),
{
content: inspect(serialize(validated)),
format: 'js',
}
)
return ResponseWrapper(res, { code: 200, data: result }) return ResponseWrapper(res, { code: 200, data: result })
}) })
@ -90,74 +110,120 @@ const Servers = RequestHandler()
const user = await get.Authorization(req.cookies.token) const user = await get.Authorization(req.cookies.token)
if (!user) return ResponseWrapper(res, { code: 401 }) if (!user) return ResponseWrapper(res, { code: 401 })
const server = await get.server.load(req.query.id) const server = await get.server.load(req.query.id)
if(!server) return ResponseWrapper(res, { code: 404, message: '존재하지 않는 서버 입니다.' }) if (!server) return ResponseWrapper(res, { code: 404, message: '존재하지 않는 서버 입니다.' })
const data = await get.serverData(req.query.id) const data = await get.serverData(req.query.id)
if((!data || server.state === 'unreachable') && (await DiscordBot.fetchInvite(server.invite).catch((e) => e.code !== RESTJSONErrorCodes.UnknownInvite))) return ResponseWrapper(res, { code: 400, message: '해당 서버의 정보를 불러올 수 없습니다.', errors: ['봇이 추방되었거나, 오프라인이 아닌지 확인하시고 다시 시도해주세요.'] }) if (
if(![data.owner, ...data.admins].includes(user)) return ResponseWrapper(res, { code: 403 }) (!data || server.state === 'unreachable') &&
(await DiscordBot.fetchInvite(server.invite).catch(
(e) => e.code !== RESTJSONErrorCodes.UnknownInvite
))
)
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) const userInfo = await get.user.load(user)
if(['reported', 'blocked'].includes(server.state) && !checkUserFlag(userInfo?.flags, 'staff')) return ResponseWrapper(res, { code: 403, message: '해당 서버는 수정할 수 없습니다.', errors: ['오류라고 생각되면 문의해주세요.'] }) 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) const csrfValidated = checkToken(req, res, req.body._csrf)
if (!csrfValidated) return if (!csrfValidated) return
const captcha = await CaptchaVerify(req.body._captcha) const captcha = await CaptchaVerify(req.body._captcha)
if(!captcha) return ResponseWrapper(res, { code: 400, message: '캡챠 검증에 실패하였습니다.' }) if (!captcha) return ResponseWrapper(res, { code: 400, message: '캡챠 검증에 실패하였습니다.' })
if(req.body.name !== server.name) return ResponseWrapper(res, { code: 400, message: '봇 이름을 입력해주세요.' }) if (req.body.name !== server.name)
return ResponseWrapper(res, { code: 400, message: '봇 이름을 입력해주세요.' })
await remove.server(server.id) await remove.server(server.id)
get.user.clear(user) get.user.clear(user)
await discordLog('SERVER/DELETE', user, (new EmbedBuilder().setDescription(`${server.name} - [${server.id}](${KoreanbotsEndPoints.URL.bot(server.id)}))`)), await discordLog(
'SERVER/DELETE',
user,
new EmbedBuilder().setDescription(
`${server.name} - [${server.id}](${KoreanbotsEndPoints.URL.bot(server.id)}))`
),
{ {
content: inspect(server), content: inspect(server),
format: 'js' format: 'js',
} }
) )
return ResponseWrapper(res, { code: 200, message: '성공적으로 삭제했습니다.' }) return ResponseWrapper(res, { code: 200, message: '성공적으로 삭제했습니다.' })
}) })
.patch(patchLimiter).patch(async (req: PatchApiRequest, res) => { .patch(patchLimiter)
.patch(async (req: PatchApiRequest, res) => {
const server = await get.server.load(req.query.id) const server = await get.server.load(req.query.id)
if(!server) return ResponseWrapper(res, { code: 404, message: '존재하지 않는 서버입니다.' }) if (!server) return ResponseWrapper(res, { code: 404, message: '존재하지 않는 서버입니다.' })
const user = await get.Authorization(req.cookies.token) const user = await get.Authorization(req.cookies.token)
if (!user) return ResponseWrapper(res, { code: 401 }) if (!user) return ResponseWrapper(res, { code: 401 })
const userInfo = await get.user.load(user) const userInfo = await get.user.load(user)
const data = await get.serverData(req.query.id) const data = await get.serverData(req.query.id)
if(!data || server.state === 'unreachable') return ResponseWrapper(res, { code: 400, message: '해당 서버의 정보를 불러올 수 없습니다.', errors: ['봇이 추방되었거나, 오프라인이 아닌지 확인하시고 다시 시도해주세요.'] }) if (!data || server.state === 'unreachable')
if(![data.owner, ...data.admins].includes(user) && !checkUserFlag(userInfo?.flags, 'staff')) return ResponseWrapper(res, { code: 403 }) return ResponseWrapper(res, {
if(['reported', 'blocked'].includes(server.state) && !checkUserFlag(userInfo?.flags, 'staff')) return ResponseWrapper(res, { code: 403, message: '해당 서버는 수정할 수 없습니다.', errors: ['오류라고 생각되면 문의해주세요.'] }) 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) const csrfValidated = checkToken(req, res, req.body._csrf)
if (!csrfValidated) return if (!csrfValidated) return
const validated = await ManageServerSchema.validate(req.body, { abortEarly: false }) const validated = await ManageServerSchema.validate(req.body, { abortEarly: false })
.then(el => el) .then((el) => el)
.catch(e => { .catch((e) => {
ResponseWrapper(res, { code: 400, errors: e.errors }) ResponseWrapper(res, { code: 400, errors: e.errors })
return null return null
}) })
if (!validated) return if (!validated) return
const invite = await DiscordBot.fetchInvite(validated.invite).catch(() => null) const invite = await DiscordBot.fetchInvite(validated.invite).catch(() => null)
if(invite?.guild.id !== server.id || invite.expiresAt) return ResponseWrapper(res, { code: 400, message: '올바르지 않은 초대코드입니다.', errors: ['입력하신 초대코드가 올바르지 않습니다. 올바른 초대코드를 입력했는지 다시 한 번 확인해주세요.', '만료되지 않는 초대코드인지 확인해주세요.'] }) if (invite?.guild.id !== server.id || invite.expiresAt)
return ResponseWrapper(res, {
code: 400,
message: '올바르지 않은 초대코드입니다.',
errors: [
'입력하신 초대코드가 올바르지 않습니다. 올바른 초대코드를 입력했는지 다시 한 번 확인해주세요.',
'만료되지 않는 초대코드인지 확인해주세요.',
],
})
const result = await update.server(req.query.id, validated) const result = await update.server(req.query.id, validated)
if(result === 0) return ResponseWrapper(res, { code: 400 }) if (result === 0) return ResponseWrapper(res, { code: 400 })
else { else {
get.server.clear(req.query.id) get.server.clear(req.query.id)
const embed = new EmbedBuilder().setDescription(`${server.name} - ([${server.id}](${KoreanbotsEndPoints.URL.server(server.id)}))`) const embed = new EmbedBuilder().setDescription(
`${server.name} - ([${server.id}](${KoreanbotsEndPoints.URL.server(server.id)}))`
)
const diffData = objectDiff( const diffData = objectDiff(
{ intro: server.intro, invite: server.invite, category: JSON.stringify(server.category) }, { 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.addFields({name: d[0], value: makeDiscordCodeblock(diff(d[1][0] || '', d[1][1] || ''), 'diff')
})
})
await discordLog('SERVER/EDIT', user, embed,
{ {
content: `--- 설명\n${diff(server.desc, validated.desc, true)}`, intro: validated.intro,
format: 'diff' invite: validated.invite,
category: JSON.stringify(validated.category),
} }
) )
diffData.forEach((d) => {
embed.addFields({
name: d[0],
value: 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 }) return ResponseWrapper(res, { code: 200 })
} }
}) })
interface GetApiRequest extends NextApiRequest { interface GetApiRequest extends NextApiRequest {
@ -175,7 +241,7 @@ interface PatchApiRequest extends GetApiRequest {
} }
interface DeleteApiRequest extends GetApiRequest { interface DeleteApiRequest extends GetApiRequest {
body: CsrfCaptcha & { name: string } | null body: (CsrfCaptcha & { name: string }) | null
} }
export default Servers export default Servers

View File

@ -4,17 +4,16 @@ import RequestHandler from '@utils/RequestHandler'
import ResponseWrapper from '@utils/ResponseWrapper' import ResponseWrapper from '@utils/ResponseWrapper'
import { get } from '@utils/Query' import { get } from '@utils/Query'
const ServerOwners = RequestHandler() const ServerOwners = RequestHandler().get(async (req: GetApiRequest, res) => {
.get(async (req: GetApiRequest, res) => { const owners = await get.serverOwners(req.query.id)
const owners = await get.serverOwners(req.query.id) if (!owners) return ResponseWrapper(res, { code: 404 })
if(!owners) return ResponseWrapper(res, { code: 404 }) return ResponseWrapper(res, { code: 200, data: owners })
return ResponseWrapper(res, { code: 200, data: owners }) })
})
interface GetApiRequest extends NextApiRequest { interface GetApiRequest extends NextApiRequest {
query: { query: {
id: string id: string
} }
} }
export default ServerOwners export default ServerOwners

View File

@ -4,7 +4,7 @@ import rateLimit from 'express-rate-limit'
import { get } from '@utils/Query' import { get } from '@utils/Query'
import RequestHandler from '@utils/RequestHandler' import RequestHandler from '@utils/RequestHandler'
import ResponseWrapper from '@utils/ResponseWrapper' import ResponseWrapper from '@utils/ResponseWrapper'
import { ReportSchema, Report} from '@utils/Yup' import { ReportSchema, Report } from '@utils/Yup'
import { webhookClients } from '@utils/DiscordBot' import { webhookClients } from '@utils/DiscordBot'
import { checkToken } from '@utils/Csrf' import { checkToken } from '@utils/Csrf'
@ -18,36 +18,40 @@ const limiter = rateLimit({
skip: (_req, res) => { skip: (_req, res) => {
res.removeHeader('X-RateLimit-Global') res.removeHeader('X-RateLimit-Global')
return false return false
} },
}) })
const ServerReport = RequestHandler().post(limiter) const ServerReport = RequestHandler()
.post(limiter)
.post(async (req: PostApiRequest, res) => { .post(async (req: PostApiRequest, res) => {
const user = await get.Authorization(req.cookies.token) const user = await get.Authorization(req.cookies.token)
if(!user) return ResponseWrapper(res, { code: 401 }) if (!user) return ResponseWrapper(res, { code: 401 })
const server = await get.server.load(req.query.id) const server = await get.server.load(req.query.id)
if(!server) return ResponseWrapper(res, { code: 404, message: '존재하지 않는 서버입니다.' }) if (!server) return ResponseWrapper(res, { code: 404, message: '존재하지 않는 서버입니다.' })
const csrfValidated = checkToken(req, res, req.body._csrf) const csrfValidated = checkToken(req, res, req.body._csrf)
if (!csrfValidated) return if (!csrfValidated) return
if(!req.body) return ResponseWrapper(res, { code: 400 }) if (!req.body) return ResponseWrapper(res, { code: 400 })
const validated: Report = await ReportSchema.validate(req.body, { abortEarly: false }) const validated: Report = await ReportSchema.validate(req.body, { abortEarly: false })
.then(el => el) .then((el) => el)
.catch(e => { .catch((e) => {
ResponseWrapper(res, { code: 400, errors: e.errors }) ResponseWrapper(res, { code: 400, errors: e.errors })
return null return null
}) })
if(!validated) return if (!validated) return
await webhookClients.internal.reportChannel.send({ threadName: `서버-${server.id}`, content: `Reported by <@${user}> (${user})\nReported **${server.name}** (${server.id})\nCategory ${req.body.category}\nDesc\n\`\`\`${req.body.description}\`\`\``, allowedMentions: { parse: ['users'] }}) await webhookClients.internal.reportChannel.send({
threadName: `서버-${server.id}`,
content: `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: '성공적으로 처리되었습니다.' }) return ResponseWrapper(res, { code: 200, message: '성공적으로 처리되었습니다.' })
}) })
interface PostApiRequest extends NextApiRequest { interface PostApiRequest extends NextApiRequest {
body: Report | null body: Report | null
query: { query: {
id: string id: string
} }
} }
export default ServerReport export default ServerReport

View File

@ -12,49 +12,56 @@ import { WebhookType } from '@types'
const ServerVote = RequestHandler() const ServerVote = RequestHandler()
.get(async (req: GetApiRequest, res) => { .get(async (req: GetApiRequest, res) => {
const server = await get.ServerAuthorization(req.headers.authorization) const server = await get.ServerAuthorization(req.headers.authorization)
if(!server) return ResponseWrapper(res, { code: 401 }) if (!server) return ResponseWrapper(res, { code: 401 })
if(req.query.id !== server) return ResponseWrapper(res, { code: 403 }) 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 => { const userID = await Yup.string()
ResponseWrapper(res, { code: 400, errors: e.errors }) .required()
return null .label('userID')
}) .validate(req.query.userID)
if(!userID) return ResponseWrapper(res, { code: 400 }) .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') const result = await get.vote(userID, server, 'server')
return ResponseWrapper(res, { code: 200, data: { voted: +new Date() < result + VOTE_COOLDOWN, lastVote: result } }) return ResponseWrapper(res, {
code: 200,
data: { voted: +new Date() < result + VOTE_COOLDOWN, lastVote: result },
})
}) })
.post(async (req: PostApiRequest, res) => { .post(async (req: PostApiRequest, res) => {
const user = await get.Authorization(req.cookies.token) const user = await get.Authorization(req.cookies.token)
if(!user) return ResponseWrapper(res, { code: 401 }) if (!user) return ResponseWrapper(res, { code: 401 })
const server = await get.server.load(req.query.id) const server = await get.server.load(req.query.id)
if (!server) return ResponseWrapper(res, { code: 404, message: '존재하지 않는 서버입니다.' }) if (!server) return ResponseWrapper(res, { code: 404, message: '존재하지 않는 서버입니다.' })
const csrfValidated = checkToken(req, res, req.body._csrf) const csrfValidated = checkToken(req, res, req.body._csrf)
if (!csrfValidated) return if (!csrfValidated) return
const captcha = await CaptchaVerify(req.body._captcha) const captcha = await CaptchaVerify(req.body._captcha)
if(!captcha) return ResponseWrapper(res, { code: 400, message: '캡챠 검증에 실패하였습니다.' }) if (!captcha) return ResponseWrapper(res, { code: 400, message: '캡챠 검증에 실패하였습니다.' })
const vote = await put.voteServer(user, server.id) const vote = await put.voteServer(user, server.id)
if(vote === null) return ResponseWrapper(res, { code: 401 }) if (vote === null) return ResponseWrapper(res, { code: 401 })
else if(vote === true) { else if (vote === true) {
sendWebhook(server, { sendWebhook(server, {
type: 'server', type: 'server',
data: { data: {
guildId: server.id, guildId: server.id,
type: WebhookType.HeartChange, type: WebhookType.HeartChange,
before: server.votes, before: server.votes,
after: server.votes + 1, after: server.votes + 1,
userId: user userId: user,
}, },
timestamp: Date.now() timestamp: Date.now(),
}) })
return ResponseWrapper(res, { code: 200 }) return ResponseWrapper(res, { code: 200 })
} } else return ResponseWrapper(res, { code: 429, data: { retryAfter: vote } })
else return ResponseWrapper(res, { code: 429, data: { retryAfter: vote } })
}) })
interface ApiRequest extends NextApiRequest { interface ApiRequest extends NextApiRequest {
query: { query: {
id: string id: string
} }
} }
interface GetApiRequest extends ApiRequest { interface GetApiRequest extends ApiRequest {
@ -65,8 +72,8 @@ interface GetApiRequest extends ApiRequest {
} }
interface PostApiRequest extends ApiRequest { interface PostApiRequest extends ApiRequest {
body: { body: {
_captcha: string _captcha: string
_csrf: string _csrf: string
} }
} }
export default ServerVote export default ServerVote

View File

@ -1,10 +1,10 @@
import { NextApiRequest} from 'next' import { NextApiRequest } from 'next'
import rateLimit from 'express-rate-limit' import rateLimit from 'express-rate-limit'
import { get } from '@utils/Query' import { get } from '@utils/Query'
import RequestHandler from '@utils/RequestHandler' import RequestHandler from '@utils/RequestHandler'
import ResponseWrapper from '@utils/ResponseWrapper' import ResponseWrapper from '@utils/ResponseWrapper'
import { ReportSchema, Report} from '@utils/Yup' import { ReportSchema, Report } from '@utils/Yup'
import { webhookClients } from '@utils/DiscordBot' import { webhookClients } from '@utils/DiscordBot'
import { checkToken } from '@utils/Csrf' import { checkToken } from '@utils/Csrf'
@ -17,36 +17,46 @@ const limiter = rateLimit({
skip: (_req, res) => { skip: (_req, res) => {
res.removeHeader('X-RateLimit-Global') res.removeHeader('X-RateLimit-Global')
return false return false
} },
}) })
const UserReport = RequestHandler().post(limiter) const UserReport = RequestHandler()
.post(limiter)
.post(async (req: PostApiRequest, res) => { .post(async (req: PostApiRequest, res) => {
const user = await get.Authorization(req.cookies.token) const user = await get.Authorization(req.cookies.token)
if(!user) return ResponseWrapper(res, { code: 401 }) if (!user) return ResponseWrapper(res, { code: 401 })
const userInfo = await get.user.load(req.query.id) const userInfo = await get.user.load(req.query.id)
if(!userInfo) return ResponseWrapper(res, { code: 404, message: '존재하지 않는 유저입니다.' }) if (!userInfo) return ResponseWrapper(res, { code: 404, message: '존재하지 않는 유저입니다.' })
const csrfValidated = checkToken(req, res, req.body._csrf) const csrfValidated = checkToken(req, res, req.body._csrf)
if (!csrfValidated) return if (!csrfValidated) return
if(!req.body) return ResponseWrapper(res, { code: 400 }) if (!req.body) return ResponseWrapper(res, { code: 400 })
const validated: Report = await ReportSchema.validate(req.body, { abortEarly: false }) const validated: Report = await ReportSchema.validate(req.body, { abortEarly: false })
.then(el => el) .then((el) => el)
.catch(e => { .catch((e) => {
ResponseWrapper(res, { code: 400, errors: e.errors }) ResponseWrapper(res, { code: 400, errors: e.errors })
return null return null
}) })
if(!validated) return if (!validated) return
await webhookClients.internal.reportChannel.send({ threadName: `유저-${userInfo.id}`, content: `Reported by <@${user}> (${user})\nReported **${userInfo.tag === '0' ? userInfo.globalName + ' @' + userInfo.username : userInfo.username + '#' + userInfo.tag}** <@${userInfo.id}> (${userInfo.id})\nCategory ${req.body.category}\nDesc\n\`\`\`${req.body.description}\`\`\``, allowedMentions: { parse: ['users'] }}) await webhookClients.internal.reportChannel.send({
threadName: `유저-${userInfo.id}`,
content: `Reported by <@${user}> (${user})\nReported **${
userInfo.tag === '0'
? userInfo.globalName + ' @' + userInfo.username
: userInfo.username + '#' + userInfo.tag
}** <@${userInfo.id}> (${userInfo.id})\nCategory ${req.body.category}\nDesc\n\`\`\`${
req.body.description
}\`\`\``,
allowedMentions: { parse: ['users'] },
})
return ResponseWrapper(res, { code: 200, message: '성공적으로 처리되었습니다.' }) return ResponseWrapper(res, { code: 200, message: '성공적으로 처리되었습니다.' })
}) })
interface PostApiRequest extends NextApiRequest { interface PostApiRequest extends NextApiRequest {
body: Report | null body: Report | null
query: { query: {
id: string id: string
} }
} }
export default UserReport export default UserReport

View File

@ -20,8 +20,8 @@ const Widget = RequestHandler().get(async (req: ApiRequest, res: NextApiResponse
scale, scale,
icon, icon,
}) })
.then(el => el) .then((el) => el)
.catch(e => { .catch((e) => {
ResponseWrapper(res, { code: 400, errors: e.errors }) ResponseWrapper(res, { code: 400, errors: e.errors })
return null return null
}) })
@ -34,8 +34,8 @@ const Widget = RequestHandler().get(async (req: ApiRequest, res: NextApiResponse
const userImage = !data.avatar const userImage = !data.avatar
? null ? null
: await get.images.user.load( : await get.images.user.load(
DiscordEnpoints.CDN.user(data.id, data.avatar, { format: 'png', size: 128 }) DiscordEnpoints.CDN.user(data.id, data.avatar, { format: 'png', size: 128 })
) )
const img = const img =
userImage || userImage ||
(await get.images.user.load( (await get.images.user.load(

View File

@ -20,8 +20,8 @@ const Widget = RequestHandler().get(async (req: ApiRequest, res: NextApiResponse
scale, scale,
icon, icon,
}) })
.then(el => el) .then((el) => el)
.catch(e => { .catch((e) => {
ResponseWrapper(res, { code: 400, errors: e.errors }) ResponseWrapper(res, { code: 400, errors: e.errors })
return null return null
}) })
@ -34,8 +34,8 @@ const Widget = RequestHandler().get(async (req: ApiRequest, res: NextApiResponse
const userImage = !data.icon const userImage = !data.icon
? null ? null
: await get.images.user.load( : await get.images.user.load(
DiscordEnpoints.CDN.guild(data.id, data.icon, { format: 'png', size: 128 }) DiscordEnpoints.CDN.guild(data.id, data.icon, { format: 'png', size: 128 })
) )
const img = const img =
userImage || userImage ||
(await get.images.user.load( (await get.images.user.load(

View File

@ -37,290 +37,535 @@ const Modal = dynamic(() => import('@components/Modal'))
const Captcha = dynamic(() => import('@components/Captcha')) const Captcha = dynamic(() => import('@components/Captcha'))
const Login = dynamic(() => import('@components/Login')) const Login = dynamic(() => import('@components/Login'))
const ManageBotPage:NextPage<ManageBotProps> = ({ bot, user, csrfToken, theme }) => { const ManageBotPage: NextPage<ManageBotProps> = ({ bot, user, csrfToken, theme }) => {
const [ data, setData ] = useState(null) const [data, setData] = useState(null)
const [ adminModal, setAdminModal ] = useState(false) const [adminModal, setAdminModal] = useState(false)
const [ transferModal, setTransferModal ] = useState(false) const [transferModal, setTransferModal] = useState(false)
const [ deleteModal, setDeleteModal ] = useState(false) const [deleteModal, setDeleteModal] = useState(false)
const router = useRouter() const router = useRouter()
async function submitBot(value: ManageBot) { async function submitBot(value: ManageBot) {
const res = await Fetch(`/bots/${bot.id}`, { method: 'PATCH', body: JSON.stringify(cleanObject<ManageBot>(value)) }) const res = await Fetch(`/bots/${bot.id}`, {
method: 'PATCH',
body: JSON.stringify(cleanObject<ManageBot>(value)),
})
setData(res) setData(res)
} }
async function getUser(id: string) { async function getUser(id: string) {
const u = await Fetch<User>(`/users/${encodeURIComponent(id)}`) const u = await Fetch<User>(`/users/${encodeURIComponent(id)}`)
if(u.code === 200 && u.data) return u.data if (u.code === 200 && u.data) return u.data
else return null else return null
} }
if(!bot) return <NotFound /> if (!bot) return <NotFound />
if(!user) return <Login> if (!user)
<NextSeo title='봇 정보 수정하기' description='봇의 정보를 수정합니다.'/> return (
</Login> <Login>
if(!(bot.owners as User[]).find(el => el.id === user.id) && !checkUserFlag(user.flags, 'staff')) return <Forbidden /> <NextSeo title='봇 정보 수정하기' description='봇의 정보를 수정합니다.' />
</Login>
)
if (
!(bot.owners as User[]).find((el) => el.id === user.id) &&
!checkUserFlag(user.flags, 'staff')
)
return <Forbidden />
return ( return (
<Container paddingTop className='pt-5 pb-10'> <Container paddingTop className='pb-10 pt-5'>
<NextSeo title={`${bot.name} 수정하기`} description='봇의 정보를 수정합니다.'/> <NextSeo title={`${bot.name} 수정하기`} description='봇의 정보를 수정합니다.' />
<h1 className='text-3xl font-bold mb-8'> </h1> <h1 className='mb-8 text-3xl font-bold'> </h1>
<Formik initialValues={cleanObject({ <Formik
agree: false, initialValues={cleanObject({
id: bot.id, agree: false,
prefix: bot.prefix, id: bot.id,
library: bot.lib, prefix: bot.prefix,
category: bot.category, library: bot.lib,
intro: bot.intro, category: bot.category,
desc: bot.desc, intro: bot.intro,
website: bot.web, desc: bot.desc,
url: bot.url, website: bot.web,
git: bot.git, url: bot.url,
discord: bot.discord, git: bot.git,
_csrf: csrfToken discord: bot.discord,
})} _csrf: csrfToken,
validationSchema={ManageBotSchema} })}
onSubmit={submitBot}> validationSchema={ManageBotSchema}
onSubmit={submitBot}
>
{({ errors, touched, values, setFieldTouched, setFieldValue }) => ( {({ errors, touched, values, setFieldTouched, setFieldValue }) => (
<Form> <Form>
<div className='md:flex text-center md:text-left'> <div className='text-center md:flex md:text-left'>
<DiscordAvatar userID={bot.id} className='md:mx-1 mx-auto rounded-full'/> <DiscordAvatar userID={bot.id} className='mx-auto rounded-full md:mx-1' />
<div className='md:w-2/3 px-8 py-6'> <div className='px-8 py-6 md:w-2/3'>
<h1 className='text-3xl font-bold'>{bot.name}#{bot.tag}</h1> <h1 className='text-3xl font-bold'>
{bot.name}#{bot.tag}
</h1>
<h2>ID: {bot.id}</h2> <h2>ID: {bot.id}</h2>
</div> </div>
</div> </div>
{ {data ? (
data ? data.code === 200 ? <div className='mt-4'> data.code === 200 ? (
<Redirect to={makeBotURL(bot)}> <div className='mt-4'>
<Message type='success'> <Redirect to={makeBotURL(bot)}>
<h2 className='text-lg font-extrabold'> .</h2> <Message type='success'>
<p> !</p> <h2 className='text-lg font-extrabold'> .</h2>
<p> !</p>
</Message>
</Redirect>
</div>
) : (
<div className='mt-4'>
<Message type='error'>
<h2 className='text-lg font-extrabold'>
{data.message || '오류가 발생했습니다.'}
</h2>
<ul className='list-inside list-disc'>
{data.errors?.map((el, n) => <li key={n}>{el}</li>)}
</ul>
</Message> </Message>
</Redirect> </div>
)
</div> : <div className='mt-4'> ) : (
<Message type='error'> ''
<h2 className='text-lg font-extrabold'>{data.message || '오류가 발생했습니다.'}</h2> )}
<ul className='list-disc list-inside'> <Label
{data.errors?.map((el, n) => <li key={n}>{el}</li>)} For='prefix'
</ul> label='접두사'
</Message> labelDesc='봇의 사용시 앞 쪽에 붙은 기호를 의미합니다. (Prefix)'
</div> : '' error={errors.prefix && touched.prefix ? errors.prefix : null}
} short
<Label For='prefix' label='접두사' labelDesc='봇의 사용시 앞 쪽에 붙은 기호를 의미합니다. (Prefix)' error={errors.prefix && touched.prefix ? errors.prefix : null} short required> required
>
<Input name='prefix' placeholder='!' /> <Input name='prefix' placeholder='!' />
</Label> </Label>
<Label For='library' label='라이브러리' labelDesc='봇에 사용된 라이브러리를 선택해주세요. 해당되는 라이브러리가 없다면 기타를 선택해주세요.' short required error={errors.library && touched.library ? errors.library : null}> <Label
<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)} /> For='library'
label='라이브러리'
labelDesc='봇에 사용된 라이브러리를 선택해주세요. 해당되는 라이브러리가 없다면 기타를 선택해주세요.'
short
required
error={errors.library && touched.library ? errors.library : null}
>
<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>
<Label For='category' label='카테고리' labelDesc='봇에 해당되는 카테고리를 선택해주세요' required error={errors.category && touched.category ? errors.category as string : null}> <Label
<Selects options={botCategories.map(el=> ({ label: el, value: el }))} handleChange={(value) => { For='category'
setFieldValue('category', value.map(v=> v.value)) label='카테고리'
}} handleTouch={() => setFieldTouched('category', true)} values={values.category as string[]} setValues={(value) => setFieldValue('category', value)} /> labelDesc='봇에 해당되는 카테고리를 선택해주세요'
<span className='text-gray-400 mt-1 text-sm'> 3 . . <strong> .</strong></span> required
error={errors.category && touched.category ? (errors.category as string) : null}
>
<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='mt-1 text-sm text-gray-400'>
3 . .{' '}
<strong> .</strong>
</span>
</Label> </Label>
<Divider /> <Divider />
<Label For='website' label='웹사이트' labelDesc='봇의 웹사이트를 작성해주세요.' error={errors.website && touched.website ? errors.website : null}> <Label
For='website'
label='웹사이트'
labelDesc='봇의 웹사이트를 작성해주세요.'
error={errors.website && touched.website ? errors.website : null}
>
<Input name='website' placeholder='https://koreanbots.dev' /> <Input name='website' placeholder='https://koreanbots.dev' />
</Label> </Label>
<Label For='git' label='Git URL' labelDesc='봇 소스코드의 Git 주소를 입력해주세요 (오픈소스인 경우)' error={errors.git && touched.git ? errors.git : null}> <Label
<Input name='git' placeholder='https://github.com/koreanbots/koreanbots'/> For='git'
label='Git URL'
labelDesc='봇 소스코드의 Git 주소를 입력해주세요 (오픈소스인 경우)'
error={errors.git && touched.git ? errors.git : null}
>
<Input name='git' placeholder='https://github.com/koreanbots/koreanbots' />
</Label> </Label>
<Label For='inviteLink' label='초대링크' labelDesc='봇의 초대링크입니다. 비워두시면 자동으로 생성합니다.' error={errors.url && touched.url ? errors.url : null}> <Label
<Input name='url' placeholder='https://discord.com/oauth2/authorize?client_id=653534001742741552&scope=bot&permissions=0' /> For='inviteLink'
<span className='text-gray-400 mt-1 text-sm'> label='초대링크'
labelDesc='봇의 초대링크입니다. 비워두시면 자동으로 생성합니다.'
error={errors.url && touched.url ? errors.url : null}
>
<Input
name='url'
placeholder='https://discord.com/oauth2/authorize?client_id=653534001742741552&scope=bot&permissions=0'
/>
<span className='mt-1 text-sm text-gray-400'>
<Link <Link
href='/calculator' href='/calculator'
rel='noreferrer' rel='noreferrer'
target='_blank' target='_blank'
className='text-blue-500 hover:text-blue-400'> className='text-blue-500 hover:text-blue-400'
>
</Link> !
</Link>
!
</span> </span>
</Label> </Label>
<Label For='discord' label='지원 디스코드 서버' labelDesc='봇의 지원 디스코드 서버를 입력해주세요. (봇에 대해 도움을 받을 수 있는 공간입니다.)' error={errors.discord && touched.discord ? errors.discord : null} short> <Label
For='discord'
label='지원 디스코드 서버'
labelDesc='봇의 지원 디스코드 서버를 입력해주세요. (봇에 대해 도움을 받을 수 있는 공간입니다.)'
error={errors.discord && touched.discord ? errors.discord : null}
short
>
<div className='flex items-center'> <div className='flex items-center'>
discord.gg/<Input name='discord' placeholder='JEh53MQ' /> discord.gg/
<Input name='discord' placeholder='JEh53MQ' />
</div> </div>
</Label> </Label>
<Divider /> <Divider />
<Label For='intro' label='봇 소개' labelDesc='봇을 소개할 수 있는 간단한 설명을 적어주세요. (최대 60자)' error={errors.intro && touched.intro ? errors.intro : null} required> <Label
For='intro'
label='봇 소개'
labelDesc='봇을 소개할 수 있는 간단한 설명을 적어주세요. (최대 60자)'
error={errors.intro && touched.intro ? errors.intro : null}
required
>
<Input name='intro' placeholder='국내 봇을 한 곳에서.' /> <Input name='intro' placeholder='국내 봇을 한 곳에서.' />
</Label> </Label>
<Label For='intro' label='봇 설명' labelDesc={<> ! ( 1500)<br/> !</>} error={errors.desc && touched.desc ? errors.desc : null} required> <Label
<TextArea name='desc' placeholder='봇에 대해 최대한 자세히 설명해주세요!' theme={theme === 'dark' ? 'dark' : 'light'} value={values.desc} setValue={(value) => setFieldValue('desc', value)} max={1500} /> For='intro'
label='봇 설명'
labelDesc={
<>
! ( 1500)
<br />
!
</>
}
error={errors.desc && touched.desc ? errors.desc : null}
required
>
<TextArea
name='desc'
placeholder='봇에 대해 최대한 자세히 설명해주세요!'
theme={theme === 'dark' ? 'dark' : 'light'}
value={values.desc}
setValue={(value) => setFieldValue('desc', value)}
max={1500}
/>
</Label> </Label>
<Label For='preview' label='설명 미리보기' labelDesc='다음 결과는 실제와 다를 수 있습니다'> <Label
For='preview'
label='설명 미리보기'
labelDesc='다음 결과는 실제와 다를 수 있습니다'
>
<Segment> <Segment>
<Markdown text={values.desc} /> <Markdown text={values.desc} />
</Segment> </Segment>
</Label> </Label>
<Divider /> <Divider />
<p className='text-base mt-2 mb-5'> <p className='mb-5 mt-2 text-base'>
<span className='text-red-500 font-semibold'> *</span> = <span className='font-semibold text-red-500'> *</span> =
</p> </p>
<Button type='submit' onClick={() => window.scrollTo({ top: 0 })}> <Button type='submit' onClick={() => window.scrollTo({ top: 0 })}>
<> <>
<i className='far fa-save'/> <i className='far fa-save' />
</> </>
</Button> </Button>
</Form> </Form>
)} )}
</Formik> </Formik>
{ {(checkUserFlag(user.flags, 'staff') || (bot.owners as User[])[0].id === user.id) && (
(checkUserFlag(user.flags, 'staff') || (bot.owners as User[])[0].id === user.id) && <div className='py-4'> <div className='py-4'>
<Divider /> <Divider />
<h2 className='text-2xl font-semibold pb-2'></h2> <h2 className='pb-2 text-2xl font-semibold'></h2>
<Segment> <Segment>
<div className='lg:flex items-center'> <div className='items-center lg:flex'>
<div className='grow py-1'> <div className='grow py-1'>
<h3 className='text-lg font-semibold'> </h3> <h3 className='text-lg font-semibold'> </h3>
<p className='text-gray-400'> .</p> <p className='text-gray-400'> .</p>
</div> </div>
<Button onClick={() => setAdminModal(true)} className='h-10 bg-red-500 hover:opacity-80 text-white lg:w-1/8'><i className='fas fa-user-cog' /> </Button> <Button
<Modal full header='관리자 수정' isOpen={adminModal} dark={theme === 'dark'} onClose={() => setAdminModal(false)} closeIcon> onClick={() => setAdminModal(true)}
<Formik initialValues={{ owners: (bot.owners as User[]), id: '', _captcha: '' }} onSubmit={async (v) => { className='lg:w-1/8 h-10 bg-red-500 text-white hover:opacity-80'
const res = await Fetch(`/bots/${bot.id}/owners`, { method: 'PATCH', body: JSON.stringify({ >
_captcha: v._captcha, <i className='fas fa-user-cog' />
_csrf: csrfToken, </Button>
owners: v.owners.map(el => el.id) <Modal
}) }) full
if(res.code === 200) { header='관리자 수정'
alert('성공적으로 수정했습니다.') isOpen={adminModal}
router.push(makeBotURL(bot)) dark={theme === 'dark'}
} else { onClose={() => setAdminModal(false)}
alert(res.message) closeIcon
setAdminModal(false) >
} <Formik
}}> initialValues={{ owners: bot.owners as User[], id: '', _captcha: '' }}
{ onSubmit={async (v) => {
({ values, setFieldValue }) => <Form> const res = await Fetch(`/bots/${bot.id}/owners`, {
method: 'PATCH',
body: JSON.stringify({
_captcha: v._captcha,
_csrf: csrfToken,
owners: v.owners.map((el) => el.id),
}),
})
if (res.code === 200) {
alert('성공적으로 수정했습니다.')
router.push(makeBotURL(bot))
} else {
alert(res.message)
setAdminModal(false)
}
}}
>
{({ values, setFieldValue }) => (
<Form>
<Message type='warning'> <Message type='warning'>
<p> . .</p> <p>
.
.
</p>
</Message> </Message>
<div className='py-4'> <div className='py-4'>
<h2 className='text-md my-1'> ID를 .</h2> <h2 className='text-md my-1'> ID를 .</h2>
<div className='flex flex-wrap'> <div className='flex flex-wrap'>
{ {(values.owners as User[]).map((el, n) => (
(values.owners as User[]).map((el, n) => <Tag className='flex items-center' text={<> <Tag
<DiscordAvatar userID={el.id} size={128} className='w-6 h-6 mr-1 rounded-full' /> {el.tag === '0' ? `${el.globalName} (@${el.username})` : `${el.username}#${el.tag}`} className='flex items-center'
{ text={
n !== 0 && <button className='ml-0.5 hover:text-red-500' onClick={() => { <>
setFieldValue('owners', (() => { <DiscordAvatar
const arr = [...values.owners] userID={el.id}
arr.splice(n, 1) size={128}
return arr className='mr-1 h-6 w-6 rounded-full'
})()) />{' '}
}}> {el.tag === '0'
<i className='fas fa-times' /> ? `${el.globalName} (@${el.username})`
</button> : `${el.username}#${el.tag}`}
{n !== 0 && (
<button
className='ml-0.5 hover:text-red-500'
onClick={() => {
setFieldValue(
'owners',
(() => {
const arr = [...values.owners]
arr.splice(n, 1)
return arr
})()
)
}}
>
<i className='fas fa-times' />
</button>
)}
</>
} }
</>} key={el.id} />) key={el.id}
} />
))}
</div> </div>
<div className='flex'> <div className='flex'>
<div className='grow pr-2'> <div className='grow pr-2'>
<Input name='id' placeholder='추가할 유저 ID' /> <Input name='id' placeholder='추가할 유저 ID' />
</div> </div>
<Button className='w-16 bg-discord-blurple' onClick={async () => { <Button
if(values.owners.find(el => el.id === values.id)) return alert('이미 존재하는 유저입니다.') className='w-16 bg-discord-blurple'
const user = await getUser(values.id) onClick={async () => {
const arr = [...values.owners] if (values.owners.find((el) => el.id === values.id))
if(!user) return alert('올바르지 않은 유저입니다.') return alert('이미 존재하는 유저입니다.')
else { const user = await getUser(values.id)
arr.push(user) const arr = [...values.owners]
setFieldValue('owners', arr) if (!user) return alert('올바르지 않은 유저입니다.')
setFieldValue('id', '') else {
} arr.push(user)
}}> setFieldValue('owners', arr)
setFieldValue('id', '')
}
}}
>
<i className='fas fa-user-plus text-white' /> <i className='fas fa-user-plus text-white' />
</Button> </Button>
</div> </div>
</div> </div>
<Captcha dark={theme === 'dark'} onVerify={(k) => setFieldValue('_captcha', k)} /> <Captcha
<Button disabled={!values._captcha} className={`mt-2 bg-red-500 text-white ${!values._captcha ? 'opacity-80' : 'hover:opacity-80'}`} type='submit'><i className='fas fa-save text-sm' /> </Button> dark={theme === 'dark'}
onVerify={(k) => setFieldValue('_captcha', k)}
/>
<Button
disabled={!values._captcha}
className={`mt-2 bg-red-500 text-white ${
!values._captcha ? 'opacity-80' : 'hover:opacity-80'
}`}
type='submit'
>
<i className='fas fa-save text-sm' />
</Button>
</Form> </Form>
} )}
</Formik> </Formik>
</Modal> </Modal>
</div> </div>
<Divider /> <Divider />
<div className='lg:flex items-center'> <div className='items-center lg:flex'>
<div className='grow py-1'> <div className='grow py-1'>
<h3 className='text-lg font-semibold'> </h3> <h3 className='text-lg font-semibold'> </h3>
<p className='text-gray-400'> . .</p> <p className='text-gray-400'>
. .
</p>
</div> </div>
<Button onClick={() => setTransferModal(true)} className='h-10 bg-red-500 hover:opacity-80 text-white lg:w-1/8'><i className='fas fa-exchange-alt' /> </Button> <Button
<Modal full header={`${bot.name} 소유권 이전하기`} isOpen={transferModal} dark={theme === 'dark'} onClose={() => setTransferModal(false)} closeIcon> onClick={() => setTransferModal(true)}
<Formik initialValues={{ ownerID: '', name: '', _captcha: '' }} onSubmit={async (v) => { className='lg:w-1/8 h-10 bg-red-500 text-white hover:opacity-80'
const res = await Fetch(`/bots/${bot.id}/owners`, { method: 'PATCH', body: JSON.stringify({ >
_captcha: v._captcha, <i className='fas fa-exchange-alt' />
_csrf: csrfToken, </Button>
owners: [ v.ownerID ] <Modal
}) }) full
if(res.code === 200) { header={`${bot.name} 소유권 이전하기`}
alert('성공적으로 소유권을 이전했습니다.') isOpen={transferModal}
router.push('/') dark={theme === 'dark'}
} else { onClose={() => setTransferModal(false)}
alert(res.message) closeIcon
setTransferModal(false) >
} <Formik
}}> initialValues={{ ownerID: '', name: '', _captcha: '' }}
{ onSubmit={async (v) => {
({ values, setFieldValue }) => <Form> const res = await Fetch(`/bots/${bot.id}/owners`, {
method: 'PATCH',
body: JSON.stringify({
_captcha: v._captcha,
_csrf: csrfToken,
owners: [v.ownerID],
}),
})
if (res.code === 200) {
alert('성공적으로 소유권을 이전했습니다.')
router.push('/')
} else {
alert(res.message)
setTransferModal(false)
}
}}
>
{({ values, setFieldValue }) => (
<Form>
<Message type='warning'> <Message type='warning'>
<h2 className='text-2xl font-bold'>!</h2> <h2 className='text-2xl font-bold'>!</h2>
<p> , .</p> <p>
,
.
</p>
</Message> </Message>
<div className='py-4'> <div className='py-4'>
<h2 className='text-md my-1'> ID를 .</h2> <h2 className='text-md my-1'> ID를 .</h2>
<Input name='ownerID' placeholder='이전할 유저 ID' /> <Input name='ownerID' placeholder='이전할 유저 ID' />
<Divider /> <Divider />
<h2 className='text-md my-1'> <strong>{bot.name}</strong>{getJosaPicker('을')(bot.name)} .</h2> <h2 className='text-md my-1'>
<strong>{bot.name}</strong>
{getJosaPicker('을')(bot.name)} .
</h2>
<Input name='name' placeholder={bot.name} /> <Input name='name' placeholder={bot.name} />
</div> </div>
<Captcha dark={theme === 'dark'} onVerify={(k) => setFieldValue('_captcha', k)} /> <Captcha
<Button disabled={!values.ownerID || values.name !== bot.name || !values._captcha} className={`mt-4 bg-red-500 text-white ${!values.ownerID ||values.name !== bot.name || !values._captcha ? 'opacity-80' : 'hover:opacity-80'}`} type='submit'><i className='fas fa-exchange-alt' /> </Button> dark={theme === 'dark'}
onVerify={(k) => setFieldValue('_captcha', k)}
/>
<Button
disabled={!values.ownerID || values.name !== bot.name || !values._captcha}
className={`mt-4 bg-red-500 text-white ${
!values.ownerID || values.name !== bot.name || !values._captcha
? 'opacity-80'
: 'hover:opacity-80'
}`}
type='submit'
>
<i className='fas fa-exchange-alt' />
</Button>
</Form> </Form>
} )}
</Formik> </Formik>
</Modal> </Modal>
</div> </div>
<Divider /> <Divider />
<div className='lg:flex items-center'> <div className='items-center lg:flex'>
<div className='grow py-1'> <div className='grow py-1'>
<h3 className='text-lg font-semibold'> </h3> <h3 className='text-lg font-semibold'> </h3>
<p className='text-gray-400'> .</p> <p className='text-gray-400'> .</p>
</div> </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> <Button
<Modal full header={`${bot.name} 삭제하기`} isOpen={deleteModal} dark={theme === 'dark'} onClose={() => setDeleteModal(false)} closeIcon> onClick={() => setDeleteModal(true)}
<Formik initialValues={{ name: '', _captcha: '', _csrf: csrfToken }} onSubmit={async (v) => { className='lg:w-1/8 h-10 bg-red-500 text-white hover:opacity-80'
const res = await Fetch(`/bots/${bot.id}`, { method: 'DELETE', body: JSON.stringify(v) }) >
if(res.code === 200) { <i className='fas fa-trash' />
alert('성공적으로 삭제하였습니다.') </Button>
redirectTo(router, '/') <Modal
} full
else alert(res.message) header={`${bot.name} 삭제하기`}
}}> isOpen={deleteModal}
{ dark={theme === 'dark'}
({ values, setFieldValue }) => <Form> onClose={() => setDeleteModal(false)}
closeIcon
>
<Formik
initialValues={{ name: '', _captcha: '', _csrf: csrfToken }}
onSubmit={async (v) => {
const res = await Fetch(`/bots/${bot.id}`, {
method: 'DELETE',
body: JSON.stringify(v),
})
if (res.code === 200) {
alert('성공적으로 삭제하였습니다.')
redirectTo(router, '/')
} else alert(res.message)
}}
>
{({ values, setFieldValue }) => (
<Form>
<Message type='warning'> <Message type='warning'>
<p> .<br/> .</p> <p>
<p> <strong>{bot.name}</strong>{getJosaPicker('을')(bot.name)} .</p> .
<br />
.
</p>
<p>
<strong>{bot.name}</strong>
{getJosaPicker('을')(bot.name)} .
</p>
</Message> </Message>
<div className='py-4'> <div className='py-4'>
<Input name='name' placeholder={bot.name} /> <Input name='name' placeholder={bot.name} />
</div> </div>
<Captcha dark={theme === 'dark'} onVerify={(k) => setFieldValue('_captcha', k)} /> <Captcha
<Button disabled={values.name !== bot.name || !values._captcha} className={`mt-4 bg-red-500 text-white ${values.name !== bot.name || !values._captcha ? 'opacity-80' : 'hover:opacity-80'}`} type='submit'><i className='fas fa-trash' /> </Button> dark={theme === 'dark'}
onVerify={(k) => setFieldValue('_captcha', k)}
/>
<Button
disabled={values.name !== bot.name || !values._captcha}
className={`mt-4 bg-red-500 text-white ${
values.name !== bot.name || !values._captcha
? 'opacity-80'
: 'hover:opacity-80'
}`}
type='submit'
>
<i className='fas fa-trash' />
</Button>
</Form> </Form>
} )}
</Formik> </Formik>
</Modal> </Modal>
</div> </div>
</Segment> </Segment>
</div> </div>
} )}
</Container> </Container>
) )
} }
@ -328,7 +573,13 @@ const ManageBotPage:NextPage<ManageBotProps> = ({ bot, user, csrfToken, theme })
export const getServerSideProps = async (ctx: Context) => { export const getServerSideProps = async (ctx: Context) => {
const parsed = parseCookie(ctx.req) const parsed = parseCookie(ctx.req)
const user = await get.Authorization(parsed?.token) const user = await get.Authorization(parsed?.token)
return { props: { bot: await get.bot.load(ctx.query.id), user: await get.user.load(user || ''), csrfToken: getToken(ctx.req, ctx.res) } } return {
props: {
bot: await get.bot.load(ctx.query.id),
user: await get.user.load(user || ''),
csrfToken: getToken(ctx.req, ctx.res),
},
}
} }
interface ManageBotProps { interface ManageBotProps {
@ -346,4 +597,4 @@ interface Query extends ParsedUrlQuery {
id: string id: string
} }
export default ManageBotPage export default ManageBotPage

View File

@ -29,7 +29,7 @@ const Owner = dynamic(() => import('@components/Owner'))
const Segment = dynamic(() => import('@components/Segment')) const Segment = dynamic(() => import('@components/Segment'))
const LongButton = dynamic(() => import('@components/LongButton')) const LongButton = dynamic(() => import('@components/LongButton'))
const Advertisement = dynamic(() => import('@components/Advertisement')) const Advertisement = dynamic(() => import('@components/Advertisement'))
const Markdown = dynamic(() => import ('@components/Markdown')) const Markdown = dynamic(() => import('@components/Markdown'))
const Message = dynamic(() => import('@components/Message')) const Message = dynamic(() => import('@components/Message'))
const Button = dynamic(() => import('@components/Button')) const Button = dynamic(() => import('@components/Button'))
const TextArea = dynamic(() => import('@components/Form/TextArea')) const TextArea = dynamic(() => import('@components/Form/TextArea'))
@ -39,302 +39,411 @@ const NSFW = dynamic(() => import('@components/NSFW'))
const Bots: NextPage<BotsProps> = ({ data, desc, date, user, theme, csrfToken }) => { const Bots: NextPage<BotsProps> = ({ data, desc, date, user, theme, csrfToken }) => {
const bg = checkBotFlag(data?.flags, 'trusted') && data?.banner const bg = checkBotFlag(data?.flags, 'trusted') && data?.banner
const router = useRouter() const router = useRouter()
const [ nsfw, setNSFW ] = useState<boolean>() const [nsfw, setNSFW] = useState<boolean>()
const [ reportModal, setReportModal ] = useState(false) const [reportModal, setReportModal] = useState(false)
const [ reportRes, setReportRes ] = useState<ResponseProps<unknown>>(null) const [reportRes, setReportRes] = useState<ResponseProps<unknown>>(null)
useEffect(() => { useEffect(() => {
setNSFW(localStorage.nsfw) setNSFW(localStorage.nsfw)
}, []) }, [])
if (!data?.id) return <NotFound /> if (!data?.id) return <NotFound />
return ( 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` } : {}}> <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'> <Container paddingTop className='py-10'>
<NextSeo <NextSeo
title={data.name} title={data.name}
description={data.intro} description={data.intro}
twitter={{ twitter={{
cardType: 'summary_large_image' cardType: 'summary_large_image',
}} }}
openGraph={{ openGraph={{
images: [ images: [
{ {
url: KoreanbotsEndPoints.OG.bot(data.id, data.name, data.intro, data.category, [formatNumber(data.votes), formatNumber(data.servers)]), url: KoreanbotsEndPoints.OG.bot(data.id, data.name, data.intro, data.category, [
formatNumber(data.votes),
formatNumber(data.servers),
]),
width: 2048, width: 2048,
height: 1170, height: 1170,
alt: 'Bot Preview Image' alt: 'Bot Preview Image',
} },
] ],
}} }}
/> />
{ {data.state === 'blocked' ? (
data.state === 'blocked' ? <div className='pb-40'> <div className='pb-40'>
<Message type='error'> <Message type='error'>
<h2 className='text-lg font-extrabold'> .</h2> <h2 className='text-lg font-extrabold'> .</h2>
</Message> </Message>
</div> </div>
: data.category.includes('NSFW') && !nsfw ? <NSFW onClick={() => setNSFW(true)} onDisableClick={() => localStorage.nsfw = true} /> ) : data.category.includes('NSFW') && !nsfw ? (
: <> <NSFW onClick={() => setNSFW(true)} onDisableClick={() => (localStorage.nsfw = true)} />
<div className='w-full pb-2'> ) : (
{ <>
data.state === 'private' ? <Message type='info'> <div className='w-full pb-2'>
<h2 className='text-lg font-extrabold'> .</h2> {data.state === 'private' ? (
<p> . .</p> <Message type='info'>
</Message> : <h2 className='text-lg font-extrabold'>
data.state === 'reported' ? .
<Message type='error'> </h2>
<h2 className='text-lg font-extrabold'> , .</h2> <p>
<p> .</p> .
<p> <Link href='/guidelines' className='text-blue-500 hover:text-blue-400'></Link> <Link href='/discord' className='text-blue-500 hover:text-blue-400'> </Link> .</p> .
</Message> : '' </p>
} </Message>
</div> ) : data.state === 'reported' ? (
<div className='lg:flex w-full'> <Message type='error'>
<div className='w-full text-center lg:w-2/12'> <h2 className='text-lg font-extrabold'>
<DiscordAvatar , .
userID={data.id} </h2>
size={256} <p> .</p>
className='w-full rounded-full' <p>
/> {' '}
</div> <Link href='/guidelines' className='text-blue-500 hover:text-blue-400'>
<div className='grow px-5 py-12 w-full text-center lg:w-5/12 lg:text-left'>
<Tag
circular
text={
<>
<i className={`fas fa-circle text-${Status[data.status]?.color}`} />{' '}
{Status[data.status]?.text}
</>
}
/>
<h1 className='mb-2 mt-3 text-4xl font-bold' style={bg ? { color: 'white' } : {}}>
{data.name}{' '}
{checkBotFlag(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'>
{
data.state === 'ok' && <LongButton
newTab
href={`/bots/${router.query.id}/invite`}
>
<h4 className='whitespace-nowrap'>
<i className='fas fa-user-plus text-discord-blurple' />
</h4>
</LongButton>
}
<Link href={`/bots/${router.query.id}/vote`} legacyBehavior>
<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> </Link>
{ {' '}
((data.owners as User[]).find(el => el.id === user?.id) || checkUserFlag(user?.flags, 'staff')) && <LongButton href={`/bots/${data.id}/edit`}> <Link href='/discord' className='text-blue-500 hover:text-blue-400'>
<h4>
<i className='fas fa-cogs' /> </Link>
</h4> .
</LongButton> </p>
} </Message>
{ ) : (
((data.owners as User[]).find(el => el.id === user?.id) || checkUserFlag(user?.flags, 'staff')) && <LongButton onClick={async() => { ''
const res = await Fetch(`/bots/${data.id}/stats`, { method: 'PATCH'} ) )}
if(res.code !== 200) return alert(res.message) </div>
else window.location.reload() <div className='w-full lg:flex'>
}}> <div className='w-full text-center lg:w-2/12'>
<h4> <DiscordAvatar userID={data.id} size={256} className='w-full rounded-full' />
<i className='fas fa-sync' /> </div>
</h4> <div className='w-full grow px-5 py-12 text-center lg:w-5/12 lg:text-left'>
</LongButton> <Tag
} circular
text={
<>
<i className={`fas fa-circle text-${Status[data.status]?.color}`} />{' '}
{Status[data.status]?.text}
</>
}
/>
<h1 className='mb-2 mt-3 text-4xl font-bold' style={bg ? { color: 'white' } : {}}>
{data.name}{' '}
{checkBotFlag(data.flags, 'trusted') ? (
<Tooltip
placement='bottom'
overlay='해당 봇은 한국 디스코드 리스트에서 엄격한 기준을 통과한 봇입니다!'
>
<span className='text-3xl text-koreanbots-blue'>
<i className='fas fa-award' />
</span>
</Tooltip>
) : (
''
)}
</h1>
<p
className={`${
bg ? 'text-gray-300' : 'text-gray-800 dark:text-gray-300'
} text-base`}
>
{data.intro}
</p>
</div>
<div className='w-full lg:w-1/4'>
{data.state === 'ok' && (
<LongButton newTab href={`/bots/${router.query.id}/invite`}>
<h4 className='whitespace-nowrap'>
<i className='fas fa-user-plus text-discord-blurple' />
</h4>
</LongButton>
)}
<Link href={`/bots/${router.query.id}/vote`} legacyBehavior>
<LongButton>
<h4>
<i className='fas fa-heart text-red-600' />
</h4>
<span className='ml-1 rounded-lg bg-little-white-hover px-2 text-center text-sm text-black dark:bg-very-black dark:text-gray-400'>
{formatNumber(data.votes)}
</span>
</LongButton>
</Link>
{((data.owners as User[]).find((el) => el.id === user?.id) ||
checkUserFlag(user?.flags, 'staff')) && (
<LongButton href={`/bots/${data.id}/edit`}>
<h4>
<i className='fas fa-cogs' />
</h4>
</LongButton>
)}
{((data.owners as User[]).find((el) => el.id === user?.id) ||
checkUserFlag(user?.flags, 'staff')) && (
<LongButton
onClick={async () => {
const res = await Fetch(`/bots/${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 grid-cols-2 gap-4 rounded-sm bg-little-white px-4 py-4 text-black dark:bg-discord-black dark:text-gray-400'>
<div>
<i className='far fa-flag' />
</div> </div>
<div className='markdown-body text-black dark:text-gray-400'>
<code>{data.prefix}</code>
</div>
<div>
<i className='fas fa-users' />
</div>
<div>{data.servers || 'N/A'}</div>
{data.shards && data.servers > 1500 && (
<>
<div>
<i className='fas fa-sitemap' />
</div>
<div>{data.shards}</div>
</>
)}
<div>
<i className='fas fa-calendar-day' />
</div>
<div>{Day(date).fromNow(false)}</div>
{checkBotFlag(data.flags, 'verified') ? (
<Tooltip overlay='해당 봇은 디스코드측에서 인증된 봇입니다.'>
<div className='col-span-2'>
<i className='fas fa-check text-discord-blurple' />
</div>
</Tooltip>
) : (
''
)}
</div> </div>
<Divider className='px-5' /> <h2 className='3xl mb-2 mt-2 font-bold'></h2>
<div className='hidden lg:block'> <div className='flex flex-wrap'>
<Advertisement /> {data.category.map((el) => (
<Tag key={el} text={el} href={`/bots/categories/${el}`} />
))}
</div> </div>
<div className='lg:flex lg:flex-row-reverse' style={bg ? { color: 'white' } : {}}> <h2 className='3xl mb-2 mt-2 font-bold'></h2>
<div className='mb-1 w-full lg:w-1/4'> {(data.owners as User[]).map((el) => (
<h2 className='3xl mb-2 font-bold'></h2> <Owner
<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'> key={el.id}
<div> id={el.id}
<i className='far fa-flag' /> tag={el.tag}
</div> globalName={el.globalName}
<div className='markdown-body text-black dark:text-gray-400'> username={el.username}
<code>{data.prefix}</code> />
</div> ))}
<div> <div className='list grid'>
<i className='fas fa-users' /> <Link
</div> href={`/bots/${router.query.id}/report`}
<div>{data.servers || 'N/A'}</div> className='cursor-pointer text-red-600 hover:underline'
{ aria-hidden='true'
data.shards && data.servers > 1500 && <> >
<div> <i className='far fa-flag' />
<i className='fas fa-sitemap' />
</div> </Link>
<div>{data.shards}</div> <Modal
</> header={`${data.name}#${data.tag} 신고하기`}
} closeIcon
<div> isOpen={reportModal}
<i className='fas fa-calendar-day' /> onClose={() => {
</div> setReportModal(false)
<div>{Day(date).fromNow(false)}</div> setReportRes(null)
{ }}
checkBotFlag(data.flags, 'verified') ? full
<Tooltip overlay='해당 봇은 디스코드측에서 인증된 봇입니다.'> dark={theme === 'dark'}
<div className='col-span-2'> >
<i className='fas fa-check text-discord-blurple' /> {reportRes?.code === 200 ? (
<Message type='success'>
<h2 className='text-lg font-semibold'> !</h2>
<p>
!{' '}
<a className='text-blue-600 hover:text-blue-500' href='/discord'>
</a>
</p>
</Message>
) : (
<Formik
onSubmit={async (body) => {
const res = await Fetch(`/bots/${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='mb-1 text-sm text-gray-400'>
.
</p>
{reportCats.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-xs font-light text-red-500'>
{errors.category && touched.category
? (errors.category as string)
: null}
</div>
<h3 className='mt-2 font-bold'></h3>
<p className='mb-1 text-sm text-gray-400'>
.
</p>
<TextArea
name='description'
placeholder='최대한 자세하게 설명해주세요!'
theme={theme === 'dark' ? 'dark' : 'light'}
value={values.description}
setValue={(value) => setFieldValue('description', value)}
/>
<div className='mt-1 text-xs font-light text-red-500'>
{errors.description && touched.description
? errors.description
: null}
</div>
</div> </div>
</Tooltip> <div className='text-right'>
: '' <Button
} className='bg-gray-500 text-white hover:opacity-90'
</div> onClick={() => setReportModal(false)}
<h2 className='3xl mb-2 mt-2 font-bold'></h2> >
<div className='flex flex-wrap'>
{data.category.map(el => ( </Button>
<Tag key={el} text={el} href={`/bots/categories/${el}`} /> <Button
))} type='submit'
</div> className='bg-red-500 text-white hover:opacity-90'
<h2 className='3xl mb-2 mt-2 font-bold'></h2> >
{(data.owners as User[]).map(el => (
<Owner </Button>
key={el.id} </div>
id={el.id} </Form>
tag={el.tag} )}
globalName={el.globalName} </Formik>
username={el.username} )}
</Modal>
{data.discord && (
<a
rel='noopener noreferrer'
target='_blank'
className='text-discord-blurple hover:underline'
href={`https://discord.gg/${data.discord}`}
>
<i className='fab fa-discord' />
</a>
)}
{data.web && (
<a
rel='noopener noreferrer'
target='_blank'
className='text-blue-500 hover:underline'
href={data.web}
>
<i className='fas fa-globe' />
</a>
)}
{data.git && (
<a
rel='noopener noreferrer'
target='_blank'
className='hover:underline'
href={data.git}
>
<i
className={`fab fa-${git[new URL(data.git).hostname]?.icon ?? 'git-alt'}`}
/> />
))} {git[new URL(data.git).hostname]?.text ?? 'Git'}
<div className='list grid'> </a>
<Link )}
href={`/bots/${router.query.id}/report`}
className='text-red-600 hover:underline cursor-pointer'
aria-hidden='true'>
<i className='far fa-flag' />
</Link>
<Modal header={`${data.name}#${data.tag} 신고하기`} closeIcon isOpen={reportModal} onClose={() => {
setReportModal(false)
setReportRes(null)
}} full dark={theme === 'dark'}>
{
reportRes?.code === 200 ? <Message type='success'>
<h2 className='text-lg font-semibold'> !</h2>
<p> ! <a className='text-blue-600 hover:text-blue-500' href='/discord'> </a> </p>
</Message> : <Formik onSubmit={async (body) => {
const res = await Fetch(`/bots/${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>
{
reportCats.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 as string: null}</div>
<h3 className='font-bold mt-2'></h3>
<p className='text-gray-400 text-sm mb-1'> .</p>
<TextArea name='description' placeholder='최대한 자세하게 설명해주세요!' theme={theme === 'dark' ? 'dark' : 'light'} value={values.description} setValue={(value) => setFieldValue('description', value)} />
<div className='mt-1 text-red-500 text-xs font-light'>{errors.description && touched.description ? errors.description : null}</div>
</div>
<div className='text-right'>
<Button className='bg-gray-500 hover:opacity-90 text-white' onClick={()=> setReportModal(false)}></Button>
<Button type='submit' className='bg-red-500 hover:opacity-90 text-white'></Button>
</div>
</Form>
)
}
</Formik>
}
</Modal>
{data.discord && (
<a
rel='noopener noreferrer'
target='_blank'
className='text-discord-blurple hover:underline'
href={`https://discord.gg/${data.discord}`}
>
<i className='fab fa-discord' />
</a>
)}
{data.web && (
<a
rel='noopener noreferrer'
target='_blank'
className='text-blue-500 hover:underline'
href={data.web}
>
<i className='fas fa-globe' />
</a>
)}
{data.git && (
<a
rel='noopener noreferrer'
target='_blank'
className='hover:underline'
href={data.git}
>
<i className={`fab fa-${git[new URL(data.git).hostname]?.icon ?? 'git-alt'}`} />
{git[new URL(data.git).hostname]?.text ?? 'Git'}
</a>
)}
</div>
<Advertisement size='tall' />
</div>
<div className='w-full lg:pr-5 lg:w-3/4'>
{
checkBotFlag(data.flags, 'hackerthon') ? <Segment className='mt-10'>
<h1 className='text-3xl font-semibold'>
<i className='fas fa-trophy mr-4 my-2 text-amber-300' /> !
</h1>
<p> "한국 디스코드 리스트 제1회 해커톤" .</p>
<p> <a className='text-blue-500 hover:text-blue-400' href='https://blog.koreanbots.dev/first-hackathon-results/'> </a> .</p>
</Segment> : ''
}
<Segment className='my-4'>
<Markdown text={desc}/>
</Segment>
<Advertisement />
</div>
</div> </div>
</> <Advertisement size='tall' />
} </div>
<div className='w-full lg:w-3/4 lg:pr-5'>
{checkBotFlag(data.flags, 'hackerthon') ? (
<Segment className='mt-10'>
<h1 className='text-3xl font-semibold'>
<i className='fas fa-trophy my-2 mr-4 text-amber-300' />
!
</h1>
<p>
" 1
" .
</p>
<p>
{' '}
<a
className='text-blue-500 hover:text-blue-400'
href='https://blog.koreanbots.dev/first-hackathon-results/'
>
</a>
.
</p>
</Segment>
) : (
''
)}
<Segment className='my-4'>
<Markdown text={desc} />
</Segment>
<Advertisement />
</div>
</div>
</>
)}
</Container> </Container>
</div> </div>
) )
@ -343,20 +452,25 @@ const Bots: NextPage<BotsProps> = ({ data, desc, date, user, theme, csrfToken })
export const getServerSideProps = async (ctx: Context) => { export const getServerSideProps = async (ctx: Context) => {
const parsed = parseCookie(ctx.req) const parsed = parseCookie(ctx.req)
const data = await get.bot.load(ctx.query.id) const data = await get.bot.load(ctx.query.id)
if(!data) return { if (!data)
props: { return {
data props: {
data,
},
} }
}
const desc = await get.botDescSafe(data.id) const desc = await get.botDescSafe(data.id)
const user = await get.Authorization(parsed?.token) const user = await get.Authorization(parsed?.token)
if((checkBotFlag(data.flags, 'trusted') || checkBotFlag(data.flags, 'partnered')) && data.vanity && data.vanity !== ctx.query.id) { if (
(checkBotFlag(data.flags, 'trusted') || checkBotFlag(data.flags, 'partnered')) &&
data.vanity &&
data.vanity !== ctx.query.id
) {
return { return {
redirect: { redirect: {
destination: `/bots/${data.vanity}`, destination: `/bots/${data.vanity}`,
permanent: true permanent: true,
}, },
props: {} props: {},
} }
} }
return { return {
@ -365,7 +479,7 @@ export const getServerSideProps = async (ctx: Context) => {
desc, desc,
date: Number(SnowflakeUtil.deconstruct(data.id ?? '0').timestamp), date: Number(SnowflakeUtil.deconstruct(data.id ?? '0').timestamp),
user: await get.user.load(user || ''), user: await get.user.load(user || ''),
csrfToken: getToken(ctx.req, ctx.res) csrfToken: getToken(ctx.req, ctx.res),
}, },
} }
} }

View File

@ -7,14 +7,26 @@ const Invite: NextPage = () => <NotFound />
export const getServerSideProps: GetServerSideProps = async (ctx) => { export const getServerSideProps: GetServerSideProps = async (ctx) => {
const data = await get.bot.load(ctx.query.id as string) const data = await get.bot.load(ctx.query.id as string)
if(!data) return { props: {} } if (!data) return { props: {} }
const record = await Bots.updateOne({ _id: data.id, 'inviteMetrix.day': getYYMMDD() }, { $inc: { 'inviteMetrix.$.count': 1 } }) const record = await Bots.updateOne(
if(record.matchedCount === 0) await Bots.findByIdAndUpdate(data.id, { $push: { inviteMetrix: { count: 1 } } }, { upsert: true }) { _id: data.id, 'inviteMetrix.day': getYYMMDD() },
{ $inc: { 'inviteMetrix.$.count': 1 } }
)
if (record.matchedCount === 0)
await Bots.findByIdAndUpdate(
data.id,
{ $push: { inviteMetrix: { count: 1 } } },
{ upsert: true }
)
ctx.res.statusCode = 307 ctx.res.statusCode = 307
ctx.res.setHeader('Location', data.url || `https://discordapp.com/oauth2/authorize?client_id=${data.id}&scope=bot&permissions=0`) ctx.res.setHeader(
'Location',
data.url ||
`https://discordapp.com/oauth2/authorize?client_id=${data.id}&scope=bot&permissions=0`
)
return { return {
props: {} props: {},
} }
} }
export default Invite export default Invite

View File

@ -19,94 +19,144 @@ import { getJosaPicker } from 'josa'
import { reportCats } from '@utils/Constants' import { reportCats } from '@utils/Constants'
import { NextSeo } from 'next-seo' import { NextSeo } from 'next-seo'
const Container = dynamic(() => import('@components/Container')) const Container = dynamic(() => import('@components/Container'))
const Message = dynamic(() => import('@components/Message')) const Message = dynamic(() => import('@components/Message'))
const Login = dynamic(() => import('@components/Login')) const Login = dynamic(() => import('@components/Login'))
const ReportBot: NextPage<ReportBotProps> = ({ data, user, csrfToken }) => { const ReportBot: NextPage<ReportBotProps> = ({ data, user, csrfToken }) => {
const [ reportRes, setReportRes ] = useState<ResponseProps<unknown>>(null) const [reportRes, setReportRes] = useState<ResponseProps<unknown>>(null)
if(!data?.id) return <NotFound /> if (!data?.id) return <NotFound />
if(!user) return <Login> if (!user)
<NextSeo title='신고하기' /> return (
</Login> <Login>
<NextSeo title='신고하기' />
</Login>
)
return ( return (
<Container paddingTop className='py-10'> <Container paddingTop className='py-10'>
<NextSeo title={`${data.name} 신고하기`} /> <NextSeo title={`${data.name} 신고하기`} />
<Link href={makeBotURL(data)} className='text-blue-500 hover:opacity-80'> <Link href={makeBotURL(data)} className='text-blue-500 hover:opacity-80'>
<i className='fas fa-arrow-left mt-3 mb-3' /> <strong>{data.name}</strong>{getJosaPicker('로')(data.name)} <i className='fas fa-arrow-left mb-3 mt-3' /> <strong>{data.name}</strong>
{getJosaPicker('로')(data.name)}
</Link> </Link>
{ {reportRes?.code === 200 ? (
reportRes?.code === 200 ? <Message type='success'> <Message type='success'>
<h2 className='text-lg font-semibold'> !</h2> <h2 className='text-lg font-semibold'> !</h2>
<p> . <strong> <a className='text-blue-600 hover:text-blue-500' href='/discord'> </a> !!</strong></p> <p>
</Message> : <Formik onSubmit={async (body) => { .{' '}
const res = await Fetch(`/bots/${data.id}/report`, { method: 'POST', body: JSON.stringify(body) }) <strong>
setReportRes(res) {' '}
}} validationSchema={ReportSchema} initialValues={{ <a className='text-blue-600 hover:text-blue-500' href='/discord'>
category: null,
description: '', </a>
_csrf: csrfToken !!
}}> </strong>
{ </p>
({ errors, touched, values, setFieldValue }) => ( </Message>
<Form> ) : (
<div className='mb-5'> <Formik
{ onSubmit={async (body) => {
reportRes && <div className='my-5'> const res = await Fetch(`/bots/${data.id}/report`, {
<Message type='error'> method: 'POST',
<h2 className='text-lg font-semibold'>{reportRes.message}</h2> body: JSON.stringify(body),
<ul className='list-disc'> })
{reportRes.errors?.map((el, n) => <li key={n}>{el}</li>)} setReportRes(res)
</ul> }}
</Message> validationSchema={ReportSchema}
</div> initialValues={{
} category: null,
<h3 className='font-bold'> </h3> description: '',
<p className='text-gray-400 text-sm mb-1'> .</p> _csrf: csrfToken,
{ }}
reportCats.map(el => >
<div key={el}> {({ errors, touched, values, setFieldValue }) => (
<label> <Form>
<Field type='radio' name='category' value={el} className='mr-1.5 py-2' /> <div className='mb-5'>
{el} {reportRes && (
</label> <div className='my-5'>
</div> <Message type='error'>
) <h2 className='text-lg font-semibold'>{reportRes.message}</h2>
} <ul className='list-disc'>
<div className='mt-1 text-red-500 text-xs font-light'>{errors.category && touched.category ? errors.category as string: null}</div> {reportRes.errors?.map((el, n) => <li key={n}>{el}</li>)}
{ </ul>
values.category && <> </Message>
{ </div>
{ )}
[reportCats[2]]: <Message type='info'> <h3 className='font-bold'> </h3>
<h3 className='font-bold text-xl'> ?</h3> <p className='mb-1 text-sm text-gray-400'> .</p>
<p> .</p> {reportCats.map((el) => (
<p className='list-disc list-item list-inside'> 1393 | 1388</p> <div key={el}>
</Message>, <label>
[reportCats[5]]: <DMCA values={values} errors={errors} touched={touched} setFieldValue={setFieldValue} />, <Field type='radio' name='category' value={el} className='mr-1.5 py-2' />
[reportCats[6]]: <Message type='warning'> {el}
<h3 className='font-bold text-xl'> ?</h3> </label>
<p><a className='text-blue-400' target='_blank' rel='noreferrer' href='http://dis.gd/report'> </a> .</p> </div>
</Message> ))}
<div className='mt-1 text-xs font-light text-red-500'>
}[values.category] {errors.category && touched.category ? (errors.category as string) : null}
}
{
!['오픈소스 라이선스, 저작권 위반 등 권리 침해'].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> </div>
</Form> {values.category && (
) <>
} {
{
[reportCats[2]]: (
<Message type='info'>
<h3 className='text-xl font-bold'>
?
</h3>
<p> .</p>
<p className='list-item list-inside list-disc'>
1393 | 1388
</p>
</Message>
),
[reportCats[5]]: (
<DMCA
values={values}
errors={errors}
touched={touched}
setFieldValue={setFieldValue}
/>
),
[reportCats[6]]: (
<Message type='warning'>
<h3 className='text-xl font-bold'>
?
</h3>
<p>
<a
className='text-blue-400'
target='_blank'
rel='noreferrer'
href='http://dis.gd/report'
>
</a>
.
</p>
</Message>
),
}[values.category]
}
{!['오픈소스 라이선스, 저작권 위반 등 권리 침해'].includes(values.category) && (
<>
<h3 className='mt-2 font-bold'></h3>
<p className='mb-1 text-sm text-gray-400'> .</p>
<TextField
values={values}
errors={errors}
touched={touched}
setFieldValue={setFieldValue}
/>
</>
)}
</>
)}
</div>
</Form>
)}
</Formik> </Formik>
} )}
</Container> </Container>
) )
} }
@ -115,28 +165,28 @@ export const getServerSideProps = async (ctx: Context) => {
const parsed = parseCookie(ctx.req) const parsed = parseCookie(ctx.req)
const data = await get.bot.load(ctx.query.id) const data = await get.bot.load(ctx.query.id)
const user = await get.Authorization(parsed?.token) const user = await get.Authorization(parsed?.token)
return { return {
props: { props: {
csrfToken: getToken(ctx.req, ctx.res), csrfToken: getToken(ctx.req, ctx.res),
data, data,
user: await get.user.load(user || '') user: await get.user.load(user || ''),
}, },
} }
} }
interface ReportBotProps { interface ReportBotProps {
csrfToken: string csrfToken: string
data: Bot data: Bot
user: User user: User
} }
interface Context extends CsrfContext { interface Context extends CsrfContext {
query: URLQuery query: URLQuery
} }
interface URLQuery extends ParsedUrlQuery { interface URLQuery extends ParsedUrlQuery {
id: string id: string
} }
export default ReportBot export default ReportBot

View File

@ -19,7 +19,6 @@ import { getJosaPicker } from 'josa'
import { KoreanbotsEndPoints } from '@utils/Constants' import { KoreanbotsEndPoints } from '@utils/Constants'
import { NextSeo } from 'next-seo' import { NextSeo } from 'next-seo'
const Container = dynamic(() => import('@components/Container')) const Container = dynamic(() => import('@components/Container'))
const DiscordAvatar = dynamic(() => import('@components/DiscordAvatar')) const DiscordAvatar = dynamic(() => import('@components/DiscordAvatar'))
const Button = dynamic(() => import('@components/Button')) const Button = dynamic(() => import('@components/Button'))
@ -30,76 +29,122 @@ const Login = dynamic(() => import('@components/Login'))
const Message = dynamic(() => import('@components/Message')) const Message = dynamic(() => import('@components/Message'))
const VoteBot: NextPage<VoteBotProps> = ({ data, user, theme, csrfToken }) => { const VoteBot: NextPage<VoteBotProps> = ({ data, user, theme, csrfToken }) => {
const [ votingStatus, setVotingStatus ] = useState(0) const [votingStatus, setVotingStatus] = useState(0)
const [ result, setResult ] = useState<ResponseProps<{retryAfter?: number}>>(null) const [result, setResult] = useState<ResponseProps<{ retryAfter?: number }>>(null)
const router = useRouter() const router = useRouter()
if(!data?.id) return <NotFound /> if (!data?.id) return <NotFound />
if(!user) return <Login> if (!user)
<NextSeo title={data.name} description={`한국 디스코드 리스트에서 ${data.name}에 투표하세요.`} openGraph={{ return (
images: [ <Login>
{ <NextSeo
url: KoreanbotsEndPoints.CDN.avatar(data.id, { format: 'png', size: 256 }), title={data.name}
width: 256, description={`한국 디스코드 리스트에서 ${data.name}에 투표하세요.`}
height: 256, openGraph={{
alt: 'Bot Avatar' images: [
} {
] url: KoreanbotsEndPoints.CDN.avatar(data.id, { format: 'png', size: 256 }),
}} /> width: 256,
</Login> height: 256,
alt: 'Bot Avatar',
if((checkBotFlag(data.flags, 'trusted') || checkBotFlag(data.flags, 'partnered')) && data.vanity && data.vanity !== router.query.id) router.push(`/bots/${data.vanity}/vote?csrfToken=${csrfToken}`) },
],
}}
/>
</Login>
)
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 ( return (
<Container paddingTop className='py-10'> <Container paddingTop className='py-10'>
<NextSeo title={data.name} description={`한국 디스코드 리스트에서 ${data.name}에 투표하세요.`} openGraph={{ <NextSeo
images: [ title={data.name}
{ description={`한국 디스코드 리스트에서 ${data.name}에 투표하세요.`}
url: KoreanbotsEndPoints.CDN.avatar(data.id, { format: 'png', size: 256 }), openGraph={{
width: 256, images: [
height: 256, {
alt: 'Bot Avatar' url: KoreanbotsEndPoints.CDN.avatar(data.id, { format: 'png', size: 256 }),
} width: 256,
] height: 256,
}} /> alt: 'Bot Avatar',
{ },
data.state === 'blocked' ? <div className='pb-40'> ],
}}
/>
{data.state === 'blocked' ? (
<div className='pb-40'>
<Message type='error'> <Message type='error'>
<h2 className='text-lg font-extrabold'> .</h2> <h2 className='text-lg font-extrabold'> .</h2>
</Message> </Message>
</div> : <> </div>
) : (
<>
<Advertisement /> <Advertisement />
<Link href={makeBotURL(data)} className='text-blue-500 hover:opacity-80'> <Link href={makeBotURL(data)} className='text-blue-500 hover:opacity-80'>
<i className='fas fa-arrow-left mt-3 mb-3' /> <strong>{data.name}</strong>{getJosaPicker('로')(data.name)} <i className='fas fa-arrow-left mb-3 mt-3' /> <strong>{data.name}</strong>
{getJosaPicker('로')(data.name)}
</Link> </Link>
<Segment className='mb-16 py-8'> <Segment className='mb-16 py-8'>
<div className='text-center'> <div className='text-center'>
<DiscordAvatar userID={data.id} className='mx-auto w-52 h-52 bg-white mb-4 rounded-full' /> <DiscordAvatar
<Tag text={<span><i className='fas fa-heart text-red-600' /> {data.votes}</span>} dark /> userID={data.id}
<h1 className='text-3xl font-bold mt-3'>{data.name}</h1> className='mx-auto mb-4 h-52 w-52 rounded-full bg-white'
/>
<Tag
text={
<span>
<i className='fas fa-heart text-red-600' /> {data.votes}
</span>
}
dark
/>
<h1 className='mt-3 text-3xl font-bold'>{data.name}</h1>
<h4 className='text-md mt-1'>12 .</h4> <h4 className='text-md mt-1'>12 .</h4>
<div className='inline-block mt-2'> <div className='mt-2 inline-block'>
{ {votingStatus === 0 ? (
votingStatus === 0 ? <Button onClick={()=> setVotingStatus(1)}> <Button onClick={() => setVotingStatus(1)}>
<><i className='far fa-heart text-red-600'/> </> <>
</Button> <i className='far fa-heart text-red-600' />
: votingStatus === 1 ? <Captcha dark={theme === 'dark'} onVerify={async (key) => { </>
const res = await Fetch<{ retryAfter: number }|unknown>(`/bots/${data.id}/vote`, { method: 'POST', body: JSON.stringify({ _csrf: csrfToken, _captcha: key }) }) </Button>
) : votingStatus === 1 ? (
<Captcha
dark={theme === 'dark'}
onVerify={async (key) => {
const res = await Fetch<{ retryAfter: number } | unknown>(
`/bots/${data.id}/vote`,
{
method: 'POST',
body: JSON.stringify({ _csrf: csrfToken, _captcha: key }),
}
)
setResult(res) setResult(res)
setVotingStatus(2) setVotingStatus(2)
}} }}
/> />
: result.code === 200 ? <h2 className='text-2xl font-bold'> !</h2> ) : result.code === 200 ? (
: result.code === 429 ? <> <h2 className='text-2xl font-bold'> !</h2>
<h2 className='text-2xl font-bold'> .</h2> ) : result.code === 429 ? (
<h4 className='text-md mt-1'>{Day(+new Date() + result.data?.retryAfter).fromNow()} .</h4> <>
</> <h2 className='text-2xl font-bold'> .</h2>
: <p>{result.message}</p> <h4 className='text-md mt-1'>
} {Day(+new Date() + result.data?.retryAfter).fromNow()}
.
</h4>
</>
) : (
<p>{result.message}</p>
)}
</div> </div>
</div> </div>
</Segment> </Segment>
<Advertisement /></> <Advertisement />
} </>
)}
</Container> </Container>
) )
} }
@ -108,30 +153,30 @@ export const getServerSideProps = async (ctx: Context) => {
const parsed = parseCookie(ctx.req) const parsed = parseCookie(ctx.req)
const data = await get.bot.load(ctx.query.id) const data = await get.bot.load(ctx.query.id)
const user = await get.Authorization(parsed?.token) const user = await get.Authorization(parsed?.token)
return { return {
props: { props: {
csrfToken: getToken(ctx.req, ctx.res), csrfToken: getToken(ctx.req, ctx.res),
data, data,
user: await get.user.load(user || '') user: await get.user.load(user || ''),
}, },
} }
} }
interface VoteBotProps { interface VoteBotProps {
csrfToken: string csrfToken: string
vote: boolean vote: boolean
data: Bot data: Bot
user: User user: User
theme: Theme theme: Theme
} }
interface Context extends CsrfContext { interface Context extends CsrfContext {
query: URLQuery query: URLQuery
} }
interface URLQuery extends ParsedUrlQuery { interface URLQuery extends ParsedUrlQuery {
id: string id: string
} }
export default VoteBot export default VoteBot

View File

@ -20,62 +20,88 @@ const Markdown = dynamic(() => import('@components/Markdown'))
const NSFW = dynamic(() => import('@components/NSFW')) const NSFW = dynamic(() => import('@components/NSFW'))
const Category: NextPage<CategoryProps> = ({ data, query }) => { const Category: NextPage<CategoryProps> = ({ data, query }) => {
const [ nsfw, setNSFW ] = useState<boolean>() const [nsfw, setNSFW] = useState<boolean>()
const router = useRouter() const router = useRouter()
useEffect(() => { useEffect(() => {
setNSFW(localStorage.nsfw) setNSFW(localStorage.nsfw)
}, []) }, [])
if(!data || data.data.length === 0 || data.totalPage < Number(query.page)) return <NotFound message={data?.data.length === 0 ? '해당 카테고리에 해당되는 봇이 존재하지 않습니다.' : null} /> if (!data || data.data.length === 0 || data.totalPage < Number(query.page))
return <> return (
<Hero type='bots' header={`${query.category} 카테고리 봇들`} description={`다양한 "${query.category}" 카테고리의 봇들을 만나보세요.`} /> <NotFound
{ message={
query.category === 'NSFW' && !nsfw ? <NSFW onClick={() => setNSFW(true)} onDisableClick={() => localStorage.nsfw = true} /> data?.data.length === 0 ? '해당 카테고리에 해당되는 봇이 존재하지 않습니다.' : null
: <Container> }
{ />
router.query.category === '빗금 명령어' && <Segment className='mb-4'> )
<h1 className='text-2xl font-bold pt-3.5 pb-1'> </h1> return (
<Markdown text={'빗금 명렁어는 디스코드 채팅창에 `/` 를 입력하여 사용할 수 있습니다.'} /> <>
<Hero
type='bots'
header={`${query.category} 카테고리 봇들`}
description={`다양한 "${query.category}" 카테고리의 봇들을 만나보세요.`}
/>
{query.category === 'NSFW' && !nsfw ? (
<NSFW onClick={() => setNSFW(true)} onDisableClick={() => (localStorage.nsfw = true)} />
) : (
<Container>
{router.query.category === '빗금 명령어' && (
<Segment className='mb-4'>
<h1 className='pb-1 pt-3.5 text-2xl font-bold'> </h1>
<Markdown
text={'빗금 명렁어는 디스코드 채팅창에 `/` 를 입력하여 사용할 수 있습니다.'}
/>
</Segment> </Segment>
} )}
<Advertisement /> <Advertisement />
<ResponsiveGrid> <ResponsiveGrid>
{ {data.data.map((bot) => (
data.data.map(bot => <BotCard key={bot.id} bot={bot} /> ) <BotCard key={bot.id} bot={bot} />
} ))}
</ResponsiveGrid> </ResponsiveGrid>
<Paginator totalPage={data.totalPage} currentPage={data.currentPage} pathname={`/bots/categories/${query.category}`} /> <Paginator
totalPage={data.totalPage}
currentPage={data.currentPage}
pathname={`/bots/categories/${query.category}`}
/>
<Advertisement /> <Advertisement />
</Container> </Container>
} )}
</> </>
)
} }
export const getServerSideProps = async (ctx: Context) => { export const getServerSideProps = async (ctx: Context) => {
let data: List<Bot> let data: List<Bot>
if(!ctx.query.page) ctx.query.page = '1' if (!ctx.query.page) ctx.query.page = '1'
const validate = await botCategoryListArgumentSchema.validate(ctx.query).then(el => el).catch(() => null) const validate = await botCategoryListArgumentSchema
if(!validate || isNaN(Number(ctx.query.page))) data = null .validate(ctx.query)
else data = await get.list.category.load(JSON.stringify({ page: Number(ctx.query.page), category: ctx.query.category })) .then((el) => el)
.catch(() => null)
if (!validate || isNaN(Number(ctx.query.page))) data = null
else
data = await get.list.category.load(
JSON.stringify({ page: Number(ctx.query.page), category: ctx.query.category })
)
return { return {
props: { props: {
data, data,
query: ctx.query query: ctx.query,
} },
} }
} }
interface CategoryProps { interface CategoryProps {
data: List<Bot> data: List<Bot>
query: URLQuery query: URLQuery
} }
interface Context extends NextPageContext { interface Context extends NextPageContext {
query: URLQuery query: URLQuery
} }
interface URLQuery extends ParsedUrlQuery { interface URLQuery extends ParsedUrlQuery {
category: string category: string
page?: string page?: string
} }
export default Category export default Category

View File

@ -4,32 +4,56 @@ import { NextSeo } from 'next-seo'
import { botCategories, botCategoryIcon } from '@utils/Constants' import { botCategories, botCategoryIcon } from '@utils/Constants'
const Container = dynamic(() => import('@components/Container')) const Container = dynamic(() => import('@components/Container'))
const Advertisement = dynamic(() => import('@components/Advertisement')) const Advertisement = dynamic(() => import('@components/Advertisement'))
const Tag = dynamic(() => import('@components/Tag')) const Tag = dynamic(() => import('@components/Tag'))
const Segment = dynamic(() => import('@components/Segment')) const Segment = dynamic(() => import('@components/Segment'))
const Categories:NextPage = () => { const Categories: NextPage = () => {
return <Container paddingTop> return (
<NextSeo title='전체 카테고리' description='한국 디스코드 리스트의 전체 카테고리입니다.' /> <Container paddingTop>
<h1 className='text-2xl font-bold mt-2 mb-5'> </h1> <NextSeo title='전체 카테고리' description='한국 디스코드 리스트의 전체 카테고리입니다.' />
<Segment className='mb-10'> <h1 className='mb-5 mt-2 text-2xl font-bold'> </h1>
<div className='text-center flex flex-wrap mt-1.5'> <Segment className='mb-10'>
{ <div className='mt-1.5 flex flex-wrap text-center'>
botCategories.map(t => <Tag key={t} text={<> {botCategories.map((t) => (
{ <Tag
{ '빗금 명령어': <span className='fa-stack' style={{ fontSize: '1em', height: '1.2em', lineHeight: '1em', width: '20px', verticalAlign: 'middle' }}> key={t}
<i className='fas fa-square fa-stack-1x fa-md' /> text={
<i className='fas fa-slash fa-rotate-90 fa-xs fa-stack-1x fa-inverse' style={{ fontSize: '0.3rem' }} /> <>
</span> }[t] ?? <i className={botCategoryIcon[t]} /> {{
} {t} '빗금 명령어': (
</>} href={`/bots/categories/${t}`} dark bigger /> ) <span
} className='fa-stack'
</div> style={{
</Segment> fontSize: '1em',
<Advertisement /> height: '1.2em',
</Container> 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={botCategoryIcon[t]} />}{' '}
{t}
</>
}
href={`/bots/categories/${t}`}
dark
bigger
/>
))}
</div>
</Segment>
<Advertisement />
</Container>
)
} }
export default Categories export default Categories

View File

@ -18,49 +18,54 @@ const Index: NextPage<IndexProps> = ({ votes, newBots, trusted }) => {
<Hero type='bots' /> <Hero type='bots' />
<Container className='pb-10'> <Container className='pb-10'>
<Advertisement /> <Advertisement />
<h1 className='text-3xl font-bold mt-10 mb-2'> <h1 className='mb-2 mt-10 text-3xl font-bold'>
<i className='far fa-heart mr-3 text-pink-600' /> <i className='far fa-heart mr-3 text-pink-600' />
</h1> </h1>
<p className='text-base'> !</p> <p className='text-base'> !</p>
<ResponsiveGrid> <ResponsiveGrid>
{ {votes.data.map((bot) => (
votes.data.map(bot=> <BotCard key={bot.id} bot={bot} />) <BotCard key={bot.id} bot={bot} />
} ))}
</ResponsiveGrid> </ResponsiveGrid>
<Paginator totalPage={votes.totalPage} currentPage={votes.currentPage} pathname='/bots/list/votes' /> <Paginator
totalPage={votes.totalPage}
currentPage={votes.currentPage}
pathname='/bots/list/votes'
/>
<Advertisement /> <Advertisement />
<h1 className='text-3xl font-bold mb-2'> <h1 className='mb-2 text-3xl font-bold'>
<i className='fa fa-check mr-3 mt-10 text-emerald-500' /> <i className='fa fa-check mr-3 mt-10 text-emerald-500' />
</h1> </h1>
<p className='text-base'> !!</p> <p className='text-base'> !!</p>
<ResponsiveGrid> <ResponsiveGrid>
{ {trusted.data.slice(0, 4).map((bot) => (
trusted.data.slice(0, 4).map(bot=> <BotCard key={bot.id} bot={bot} />) <BotCard key={bot.id} bot={bot} />
} ))}
</ResponsiveGrid> </ResponsiveGrid>
<h1 className='text-3xl font-bold mt-20 mb-2'> <h1 className='mb-2 mt-20 text-3xl font-bold'>
<i className='far fa-star mr-3 text-amber-500' /> <i className='far fa-star mr-3 text-amber-500' />
</h1> </h1>
<p className='text-base'> .</p> <p className='text-base'> .</p>
<ResponsiveGrid> <ResponsiveGrid>
{ {newBots.data.slice(0, 4).map((bot) => (
newBots.data.slice(0, 4).map(bot=> <BotCard key={bot.id} bot={bot} />) <BotCard key={bot.id} bot={bot} />
} ))}
</ResponsiveGrid> </ResponsiveGrid>
<LongButton href='/bots/list/new' center></LongButton> <LongButton href='/bots/list/new' center>
</LongButton>
<Advertisement /> <Advertisement />
</Container> </Container>
</> </>
) )
} }
export const getServerSideProps = async() => { export const getServerSideProps = async () => {
const votes = await Query.get.list.votes.load(1) const votes = await Query.get.list.votes.load(1)
const newBots = await Query.get.list.new.load(1) const newBots = await Query.get.list.new.load(1)
const trusted = await Query.get.list.trusted.load(1) const trusted = await Query.get.list.trusted.load(1)
return { props: { votes, newBots, trusted }} return { props: { votes, newBots, trusted } }
} }
interface IndexProps { interface IndexProps {

View File

@ -10,32 +10,38 @@ const BotCard = dynamic(() => import('@components/BotCard'))
const Container = dynamic(() => import('@components/Container')) const Container = dynamic(() => import('@components/Container'))
const ResponsiveGrid = dynamic(() => import('@components/ResponsiveGrid')) const ResponsiveGrid = dynamic(() => import('@components/ResponsiveGrid'))
const New:NextPage<NewProps> = ({ data }) => { const New: NextPage<NewProps> = ({ data }) => {
return <> return (
<Hero type='bots' header='새로운 봇' description='최근에 한국 디스코드 리스트에 추가된 봇들입니다!' /> <>
<Container className='pb-10'> <Hero
<Advertisement /> type='bots'
<ResponsiveGrid> header='새로운 봇'
{ description='최근에 한국 디스코드 리스트에 추가된 봇들입니다!'
data.data.map(bot => <BotCard key={bot.id} bot={bot} /> ) />
} <Container className='pb-10'>
</ResponsiveGrid> <Advertisement />
<Advertisement /> <ResponsiveGrid>
</Container> {data.data.map((bot) => (
</> <BotCard key={bot.id} bot={bot} />
))}
</ResponsiveGrid>
<Advertisement />
</Container>
</>
)
} }
export const getServerSideProps = async () => { export const getServerSideProps = async () => {
const data = await get.list.new.load(1) const data = await get.list.new.load(1)
return { return {
props: { props: {
data data,
} },
} }
} }
interface NewProps { interface NewProps {
data: List<Bot> data: List<Bot>
} }
export default New export default New

View File

@ -4,7 +4,7 @@ import dynamic from 'next/dynamic'
import { ParsedUrlQuery } from 'querystring' import { ParsedUrlQuery } from 'querystring'
import { Bot, List } from '@types' import { Bot, List } from '@types'
import { get }from '@utils/Query' import { get } from '@utils/Query'
import NotFound from '../../404' import NotFound from '../../404'
import { PageCount } from '@utils/Yup' import { PageCount } from '@utils/Yup'
@ -16,40 +16,49 @@ const ResponsiveGrid = dynamic(() => import('@components/ResponsiveGrid'))
const Container = dynamic(() => import('@components/Container')) const Container = dynamic(() => import('@components/Container'))
const Paginator = dynamic(() => import('@components/Paginator')) const Paginator = dynamic(() => import('@components/Paginator'))
const Votes:NextPage<VotesProps> = ({ data }) => { const Votes: NextPage<VotesProps> = ({ data }) => {
const router = useRouter() const router = useRouter()
if(!data || data.data.length === 0 || data.totalPage < Number(router.query.page)) return <NotFound /> if (!data || data.data.length === 0 || data.totalPage < Number(router.query.page))
return <> return <NotFound />
<Hero type='bots' header='하트 랭킹' description='하트를 많이 받은 봇들의 순위입니다!'/> return (
<section id='list'> <>
<Container className='pb-10'> <Hero type='bots' header='하트 랭킹' description='하트를 많이 받은 봇들의 순위입니다!' />
<Advertisement /> <section id='list'>
<ResponsiveGrid> <Container className='pb-10'>
{ <Advertisement />
data.data.map(bot => <BotCard key={bot.id} bot={bot} /> ) <ResponsiveGrid>
} {data.data.map((bot) => (
</ResponsiveGrid> <BotCard key={bot.id} bot={bot} />
<Paginator totalPage={data.totalPage} currentPage={data.currentPage} pathname='/bots/list/votes' /> ))}
<Advertisement /> </ResponsiveGrid>
</Container> <Paginator
</section> totalPage={data.totalPage}
</> currentPage={data.currentPage}
pathname='/bots/list/votes'
/>
<Advertisement />
</Container>
</section>
</>
)
} }
export const getServerSideProps = async (ctx:Context) => { export const getServerSideProps = async (ctx: Context) => {
let data: List<Bot> let data: List<Bot>
if(!ctx.query.page) ctx.query.page = '1' if (!ctx.query.page) ctx.query.page = '1'
const validate = await PageCount.validate(ctx.query.page).then(el => el).catch(() => null) const validate = await PageCount.validate(ctx.query.page)
if(!validate || isNaN(Number(ctx.query.page))) data = null .then((el) => el)
.catch(() => null)
if (!validate || isNaN(Number(ctx.query.page))) data = null
else data = await get.list.votes.load(Number(ctx.query.page)) else data = await get.list.votes.load(Number(ctx.query.page))
return { return {
props: { props: {
data data,
} },
} }
} }
interface VotesProps { interface VotesProps {
data: List<Bot> data: List<Bot>
} }
interface Context extends NextPageContext { interface Context extends NextPageContext {

View File

@ -8,7 +8,6 @@ import { get } from '@utils/Query'
import { SearchQuerySchema } from '@utils/Yup' import { SearchQuerySchema } from '@utils/Yup'
import { KoreanbotsEndPoints } from '@utils/Constants' import { KoreanbotsEndPoints } from '@utils/Constants'
const Hero = dynamic(() => import('@components/Hero')) const Hero = dynamic(() => import('@components/Hero'))
const Advertisement = dynamic(() => import('@components/Advertisement')) const Advertisement = dynamic(() => import('@components/Advertisement'))
const BotCard = dynamic(() => import('@components/BotCard')) const BotCard = dynamic(() => import('@components/BotCard'))
@ -18,75 +17,96 @@ const Paginator = dynamic(() => import('@components/Paginator'))
const LongButton = dynamic(() => import('@components/LongButton')) const LongButton = dynamic(() => import('@components/LongButton'))
const Redirect = dynamic(() => import('@components/Redirect')) const Redirect = dynamic(() => import('@components/Redirect'))
const SearchComponent: FC<{data: List<Bot>, query: URLQuery }> = ({ data, query }) => { const SearchComponent: FC<{ data: List<Bot>; query: URLQuery }> = ({ data, query }) => {
return <div className='py-10'> return (
{ !data || data.data.length === 0 ? <h1 className='text-3xl font-bold text-center py-20'> .</h1> : <div className='py-10'>
<> {!data || data.data.length === 0 ? (
<ResponsiveGrid> <h1 className='py-20 text-center text-3xl font-bold'> .</h1>
{ ) : (
data.data.map(el => <BotCard key={el.id} bot={el as Bot} /> ) <>
} <ResponsiveGrid>
</ResponsiveGrid> {data.data.map((el) => (
<Paginator totalPage={data.totalPage} currentPage={data.currentPage} pathname='/search' searchParams={query} /> <BotCard key={el.id} bot={el as Bot} />
</> ))}
} </ResponsiveGrid>
</div> <Paginator
totalPage={data.totalPage}
currentPage={data.currentPage}
pathname='/search'
searchParams={query}
/>
</>
)}
</div>
)
} }
const Search:NextPage<SearchProps> = ({ botData, query }) => { const Search: NextPage<SearchProps> = ({ botData, query }) => {
if(!query?.q) return <Redirect text={false} to='/' /> if (!query?.q) return <Redirect text={false} to='/' />
return <> return (
<Hero type='bots' header={`"${query.q}" 검색 결과`} description={`'${query.q}' 에 대한 검색 결과입니다.`} /> <>
<Container> <Hero
<section id='list'> type='bots'
<Advertisement /> header={`"${query.q}" 검색 결과`}
<h1 className='text-4xl font-bold'></h1> description={`'${query.q}' 에 대한 검색 결과입니다.`}
<SearchComponent data={botData} query={query} /> />
<h1 className='text-2xl font-bold py-10'> ?</h1> <Container>
<LongButton center href={KoreanbotsEndPoints.URL.searchServer(query.q)}> </LongButton> <section id='list'>
<Advertisement /> <Advertisement />
</section> <h1 className='text-4xl font-bold'></h1>
</Container> <SearchComponent data={botData} query={query} />
</> <h1 className='py-10 text-2xl font-bold'> ?</h1>
<LongButton center href={KoreanbotsEndPoints.URL.searchServer(query.q)}>
</LongButton>
<Advertisement />
</section>
</Container>
</>
)
} }
export const getServerSideProps = async(ctx: Context) => { export const getServerSideProps = async (ctx: Context) => {
if(ctx.query.query && !ctx.query.q) ctx.query.q = ctx.query.query if (ctx.query.query && !ctx.query.q) ctx.query.q = ctx.query.query
if(!ctx.query?.q) { if (!ctx.query?.q) {
return { return {
redirect: { redirect: {
destination: '/', destination: '/',
permanent: true permanent: true,
}, },
props: {} props: {},
} }
} }
if(!ctx.query.page) ctx.query.page = '1' if (!ctx.query.page) ctx.query.page = '1'
const validate = await SearchQuerySchema.validate(ctx.query).then(el => el).catch(() => null) const validate = await SearchQuerySchema.validate(ctx.query)
if(!validate || isNaN(Number(ctx.query.page))) return { props: { query: ctx.query } } .then((el) => el)
.catch(() => null)
if (!validate || isNaN(Number(ctx.query.page))) return { props: { query: ctx.query } }
else { else {
return { return {
props: { props: {
botData: await get.list.search.load(JSON.stringify({ query: ctx.query.q || '', page: ctx.query.page })).then(el => el).catch(() => null), botData: await get.list.search
query: ctx.query .load(JSON.stringify({ query: ctx.query.q || '', page: ctx.query.page }))
} .then((el) => el)
.catch(() => null),
query: ctx.query,
},
} }
} }
} }
interface SearchProps { interface SearchProps {
botData?: List<Bot> botData?: List<Bot>
query: URLQuery query: URLQuery
} }
interface Context extends NextPageContext { interface Context extends NextPageContext {
query: URLQuery query: URLQuery
} }
interface URLQuery extends ParsedUrlQuery { interface URLQuery extends ParsedUrlQuery {
q?: string q?: string
query?: string query?: string
page?: string page?: string
} }
export default Search export default Search

View File

@ -10,81 +10,112 @@ import { DiscordEnpoints, GuildPermissions } from '@utils/Constants'
const Container = dynamic(() => import('@components/Container')) const Container = dynamic(() => import('@components/Container'))
const Input = dynamic(() => import('@components/Form/Input')) const Input = dynamic(() => import('@components/Form/Input'))
const Calculator:NextPage<CalculatorProps> = ({ query }) => { const Calculator: NextPage<CalculatorProps> = ({ query }) => {
const [ value, setValue ] = useState<{[perm: string]: boolean}>({}) const [value, setValue] = useState<{ [perm: string]: boolean }>({})
const Perm = ({ name, perm, yellow }:{ name: string, perm: number, yellow?: boolean }) => { const Perm = ({ name, perm, yellow }: { name: string; perm: number; yellow?: boolean }) => {
return <li> return (
<label className='inline-flex items-center py-1'> <li>
<input className='form-checkbox text-discord-blurple bg-gray-300 h-5 w-5 rounded' type='checkbox' checked={value[perm]} onChange={() => { <label className='inline-flex items-center py-1'>
setValue({ ...value, [perm]: !value[perm] }) <input
}} /> className='form-checkbox h-5 w-5 rounded bg-gray-300 text-discord-blurple'
<span className={`ml-2.5 text-lg ${yellow ? 'text-amber-500' : ''}`}>{name}</span> type='checkbox'
</label> checked={value[perm]}
</li> onChange={() => {
setValue({ ...value, [perm]: !value[perm] })
}}
/>
<span className={`ml-2.5 text-lg ${yellow ? 'text-amber-500' : ''}`}>{name}</span>
</label>
</li>
)
} }
return <Container paddingTop className='pb-10'> return (
<NextSeo title='봇 초대링크 생성기' description='디스코드 봇 초대링크를 간편하게 생성하세요' openGraph={{ <Container paddingTop className='pb-10'>
title:'봇 초대링크 생성기', <NextSeo
description: '디스코드 봇 초대링크를 간편하게 생성하세요' title='봇 초대링크 생성기'
}} /> description='디스코드 봇 초대링크를 간편하게 생성하세요'
<h1 className='text-4xl font-bold mt-2 mb-4'> </h1> openGraph={{
<div className='text-2xl font-bold inline-flex items-center'>: {String(Object.keys(value).filter(el => value[el]).map(el => Number(el)).reduce((prev, curr) => BigInt(prev) | BigInt(curr), BigInt(0)))} title: '봇 초대링크 생성기',
<span className='ml-2 text-lg font-semibold'>= { Object.keys(value).filter(el => value[el]).map(el => `0x${Number(el).toString(16)}`).join(' | ') }</span> description: '디스코드 봇 초대링크를 간편하게 생성하세요',
</div> }}
<div className='grid gap-2 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3 mt-2'> />
<div> <h1 className='mb-4 mt-2 text-4xl font-bold'> </h1>
<h2 className='text-2xl font-bold'> </h2> <div className='inline-flex items-center text-2xl font-bold'>
<ul> :{' '}
{ {String(
GuildPermissions.general.map(el => <Perm key={el.name} name={el.name} perm={el.flag} yellow={el.twofactor} />) Object.keys(value)
} .filter((el) => value[el])
</ul> .map((el) => Number(el))
.reduce((prev, curr) => BigInt(prev) | BigInt(curr), BigInt(0))
)}
<span className='ml-2 text-lg font-semibold'>
={' '}
{Object.keys(value)
.filter((el) => value[el])
.map((el) => `0x${Number(el).toString(16)}`)
.join(' | ')}
</span>
</div> </div>
<div> <div className='mt-2 grid grid-cols-1 gap-2 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3'>
<h2 className='text-2xl font-bold'> </h2> <div>
<ul> <h2 className='text-2xl font-bold'> </h2>
{ <ul>
GuildPermissions.membership.map(el => <Perm key={el.name} name={el.name} perm={el.flag} yellow={el.twofactor} />) {GuildPermissions.general.map((el) => (
} <Perm key={el.name} name={el.name} perm={el.flag} yellow={el.twofactor} />
</ul> ))}
</ul>
</div>
<div>
<h2 className='text-2xl font-bold'> </h2>
<ul>
{GuildPermissions.membership.map((el) => (
<Perm key={el.name} name={el.name} perm={el.flag} yellow={el.twofactor} />
))}
</ul>
</div>
<div>
<h2 className='text-2xl font-bold'> </h2>
<ul>
{GuildPermissions.channel.map((el) => (
<Perm key={el.name} name={el.name} perm={el.flag} yellow={el.twofactor} />
))}
</ul>
</div>
<div>
<h2 className='text-2xl font-bold'> </h2>
<ul>
{GuildPermissions.voice.map((el) => (
<Perm key={el.name} name={el.name} perm={el.flag} />
))}
</ul>
</div>
<div>
<h2 className='text-2xl font-bold'> </h2>
<ul>
{GuildPermissions.advanced.map((el) => (
<Perm key={el.name} name={el.name} perm={el.flag} yellow={el.twofactor} />
))}
</ul>
</div>
</div> </div>
<div> <div className='py-10'>
<h2 className='text-2xl font-bold'> </h2> <span className='text-amber-500'>
<ul> = 2 , {' '}
{ <a href='https://support.discord.com/hc/ko/articles/219576828-2단계-인증-설정하기'>
GuildPermissions.channel.map(el => <Perm key={el.name} name={el.name} perm={el.flag} yellow={el.twofactor} />) 2
} </a>
</ul> .
</span>
</div> </div>
<div> <Formik
<h2 className='text-2xl font-bold'> </h2> onSubmit={() => console.log('Pong?')}
<ul> initialValues={{
{ id: query.id?.toString() || '',
GuildPermissions.voice.map(el => <Perm key={el.name} name={el.name} perm={el.flag} />) scope: 'bot',
} redirect: '',
</ul> }}
</div> >
<div> {({ values, setFieldValue }) => (
<h2 className='text-2xl font-bold'> </h2>
<ul>
{
GuildPermissions.advanced.map(el => <Perm key={el.name} name={el.name} perm={el.flag} yellow={el.twofactor} />)
}
</ul>
</div>
</div>
<div className='py-10'>
<span className='text-amber-500'> = 2 , <a href='https://support.discord.com/hc/ko/articles/219576828-2단계-인증-설정하기'>2 </a> .</span>
</div>
<Formik onSubmit={()=> console.log('Pong?')} initialValues={{
id: query.id?.toString() || '',
scope: 'bot',
redirect: ''
}}>
{
({ values, setFieldValue }) => (
<Form> <Form>
<div className='grid gap-3 lg:grid-cols-4'> <div className='grid gap-3 lg:grid-cols-4'>
<div> <div>
@ -94,7 +125,12 @@ const Calculator:NextPage<CalculatorProps> = ({ query }) => {
</div> </div>
<div> <div>
<h6> (Scope)</h6> <h6> (Scope)</h6>
<button onClick={() => setFieldValue('scope', 'bot applications.commands')} className='text-blue-500 hover:text-blue-400'> (Slash Command) ?</button> <button
onClick={() => setFieldValue('scope', 'bot applications.commands')}
className='text-blue-500 hover:text-blue-400'
>
(Slash Command) ?
</button>
<Input name='scope' placeholder='bot' /> <Input name='scope' placeholder='bot' />
</div> </div>
<div> <div>
@ -104,20 +140,37 @@ const Calculator:NextPage<CalculatorProps> = ({ query }) => {
</div> </div>
</div> </div>
<div className='mt-2'> <div className='mt-2'>
: <a rel='noreferrer' target='_blank' href={values.id ? DiscordEnpoints.InviteApplication(values.id, value, values.scope, values.redirect) : null} className='cursor-pointer text-blue-500 hover:text-blue-400'>{DiscordEnpoints.InviteApplication(values.id, value, values.scope, values.redirect)}</a> :{' '}
<a
rel='noreferrer'
target='_blank'
href={
values.id
? DiscordEnpoints.InviteApplication(
values.id,
value,
values.scope,
values.redirect
)
: null
}
className='cursor-pointer text-blue-500 hover:text-blue-400'
>
{DiscordEnpoints.InviteApplication(values.id, value, values.scope, values.redirect)}
</a>
</div> </div>
</Form> </Form>
) )}
} </Formik>
</Formik> </Container>
</Container> )
} }
export const getServerSideProps: GetServerSideProps = async (ctx) => { export const getServerSideProps: GetServerSideProps = async (ctx) => {
return { return {
props: { props: {
query: ctx.query query: ctx.query,
} },
} }
} }

Some files were not shown because too many files have changed in this diff Show More