mirror of
https://github.com/koreanbots/core.git
synced 2025-12-15 14:10:22 +00:00
v2 Release (#154)
* chore(deps): update dependency typescript to v4.2.4 (#314) Co-authored-by: Renovate Bot <bot@renovateapp.com> * fix(deps): update dependency core-js to v3.10.1 (#315) Co-authored-by: Renovate Bot <bot@renovateapp.com> * feat: camo images in bot desc * chore: added bot delete api * feat: delete button working * feat: added bot remove method * chore: added csrfCaptchaSchema * deps: update * fix: some error at callback * fix(deps): pin dependency abort-controller to 3.0.0 (#313) Co-authored-by: Renovate Bot <bot@renovateapp.com> * chore(deps): update dependency @types/node-fetch to v2.5.10 (#316) Co-authored-by: Renovate Bot <bot@renovateapp.com> * chore(deps): update dependency eslint-plugin-react to v7.23.2 (#317) Co-authored-by: Renovate Bot <bot@renovateapp.com> * style: fixed for deepscan * chore: improved user login interaction * fix(deps): update dependency @sentry/webpack-plugin to v1.15.0 (#318) * chore(deps): update dependency eslint to v7.24.0 (#320) * fix(deps): update dependency postcss to v8.2.10 (#321) Co-authored-by: Renovate Bot <bot@renovateapp.com> * ci: updated ci stuff * style: removed unnecessary script * fix: not using SENTRY_RELEASE env * chore: defaulting mysql password * chore: added sentry_dsn env and only uploading for master * ci: updated trigger * ci: passing source branch env only at push * chore(deps): update typescript-eslint monorepo to v4.22.0 (#322) Co-authored-by: Renovate Bot <bot@renovateapp.com> * chore(deps): update dependency eslint-config-prettier to v8.2.0 (#323) Co-authored-by: Renovate Bot <bot@renovateapp.com> * chore(deps): update dependency @types/react-select to v4.0.15 (#325) Co-authored-by: Renovate Bot <bot@renovateapp.com> * chore(deps): update dependency eslint-plugin-prettier to v3.4.0 (#326) Co-authored-by: Renovate Bot <bot@renovateapp.com> * chore(deps): update dependency @types/sanitize-html to v2 (#328) Co-authored-by: Renovate Bot <bot@renovateapp.com> * chore(deps): update dependency @types/node to v14.14.41 (#324) Co-authored-by: Renovate Bot <bot@renovateapp.com> * chore(deps): update dependency ts-jest to v26.5.5 (#327) Co-authored-by: Renovate Bot <bot@renovateapp.com> * ci: debugging * Update components/DeveloperLayout.tsx Co-authored-by: zero734kr <zero734kr@gmail.com> * Update components/Loader.tsx Co-authored-by: zero734kr <zero734kr@gmail.com> * Update components/ColorCard.tsx Co-authored-by: zero734kr <zero734kr@gmail.com> * Update components/ColorCard.tsx Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * fix(deps): update dependency core-js to v3.11.0 (#329) Co-authored-by: Renovate Bot <bot@renovateapp.com> * chore(deps): update dependency @types/sanitize-html to v2.3.1 (#330) Co-authored-by: Renovate Bot <bot@renovateapp.com> * fix(deps): update dependency postcss to v8.2.13 (#333) Co-authored-by: Renovate Bot <bot@renovateapp.com> * fix(deps): update dependency tailwindcss to v2.1.2 (#334) Co-authored-by: Renovate Bot <bot@renovateapp.com> * chore(deps): update dependency eslint to v7.25.0 (#335) Co-authored-by: Renovate Bot <bot@renovateapp.com> * fix(deps): update dependency @sentry/webpack-plugin to v1.15.1 (#332) Co-authored-by: Renovate Bot <bot@renovateapp.com> * chore(deps): update dependency @types/node to v14.14.43 (#339) Co-authored-by: Renovate Bot <bot@renovateapp.com> * chore(deps): update dependency @types/jest to v26.0.23 (#337) Co-authored-by: Renovate Bot <bot@renovateapp.com> * chore(deps): update dependency eslint-config-prettier to v8.3.0 (#336) Co-authored-by: Renovate Bot <bot@renovateapp.com> * chore(deps): update dependency @types/react to v17.0.4 (#338) Co-authored-by: Renovate Bot <bot@renovateapp.com> * Update utils/ShowdownExtensions.ts Co-authored-by: zero734kr <zero734kr@gmail.com> * style: fixed some styles * chore: updated api-docs git * refactor: made change on sentry * style: removed debug code * deps: removed node-mock * ci: removed env * style: code style * test: module names * chore: docker using python * chore: docker using build-base * ci: fixed syntax error * chore: changed sql type * feat: added vote * fix: version for v1 * feat: added v1 bot vote check * feat: clearing cache for deleted bot * chore: delete bot real working IMPORTANT: NOW DELETE BOT REAL WORKS! * fix: router called at non-client * style: removed space * feat: added vote check endpoint * fix: router called at non-client * fix(deps): update sentry monorepo to v6.3.5 (#331) Co-authored-by: Renovate Bot <bot@renovateapp.com> * fix(deps): update dependency core-js to v3.11.2 (#340) Co-authored-by: Renovate Bot <bot@renovateapp.com> * chore(deps): update dependency @types/react to v17.0.5 (#345) Co-authored-by: Renovate Bot <bot@renovateapp.com> * chore(deps): update typescript-eslint monorepo to v4.22.1 (#343) Co-authored-by: Renovate Bot <bot@renovateapp.com> * fix: BotCard button component rendered as Tag * feat: update docs * feat: using koreanbots cdn for og image * fix: missing querystring label * docs: some text change https://github.com/koreanbots/v2-testing/issues/72#issuecomment-807929228 * fix: removed unexpected char close: https://github.com/koreanbots/v2-testing/issues/76 * fix: redirecting at serverside * fix(deps): pin dependencies (#342) Co-authored-by: Renovate Bot <bot@renovateapp.com> * chore(deps): update dependency ts-jest to v26.5.6 (#347) Co-authored-by: Renovate Bot <bot@renovateapp.com> * fix(deps): update dependency postcss to v8.2.14 (#349) Co-authored-by: Renovate Bot <bot@renovateapp.com> * fix(deps): update dependency core-js to v3.11.3 (#348) Co-authored-by: Renovate Bot <bot@renovateapp.com> * fix: router instance called at serverside while rendering * Merge branch 'master' of https://github.com/koreanbots/koreanbots * feat: Sentry enabled only at production * fix: menu not closing close: https://github.com/koreanbots/v2-testing/issues/50 * chore: improved mobile design * fix: tooltip overflows screen close: https://github.com/koreanbots/v2-testing/issues/28 * fix: router called at server-side close: https://github.com/koreanbots/v2-testing/issues/77 * typo: fixed typo issue * typo: improved typo * fix: router called at serverside * chore: removed custom scrollbar style * style: fixed null checks * feat: added owner transfer and edit * chore: clearing cache for updates * chore: redirecting on update * chore: added button margin * feat: disabled webhook * chore: added some spaces * feat: added padding for ad * feat: remove wave * feat: added security page * chore: some margin * feat: added bug reporters * style: fixed eslint * fix(developers): https://github.com/koreanbots/v2-testing/issues/74 * chore: improved ad * feat: migrated to @sentry/nextjs * fix: card invite button fixed * chore: not releasing * chore: debugging * chore: skiping sentry auto release * feat: added docker hub build hook * fix: docker hook * fix: docker hook geting sentry dsn as build-arg * chore: added sentry envs * chore(docker): cleanup * fix: bugs at card * typo: fixed * chore: margin top at message * fix: card building weird * fix: sentry disabled * fix: query string invalid fix: https://github.com/koreanbots/v2-testing/issues/92 * fix: https://github.com/koreanbots/v2-testing/issues/94 * chore: improved style close: https://github.com/koreanbots/v2-testing/issues/83 * fix: scrollbar shown even its not overflowed fix: https://github.com/koreanbots/v2-testing/issues/86 * fix: home not displayed at dev portal fix: https://github.com/koreanbots/v2-testing/issues/84 * types: searchParams is optional prop * feat: added required field notice close: https://github.com/koreanbots/v2-testing/issues/90 * typo: fixed typo issues For https://github.com/koreanbots/v2-testing/issues/79 * fix: causing error on other git url ISSUE: https://sentry.io/share/issue/a13341dc1aab4e5aa994fee8857afff7/ * fix: handle AbortError * chore(deps): update dependency eslint to v7.26.0 (#353) Co-authored-by: Renovate Bot <bot@renovateapp.com> * fix(deps): update dependency core-js to v3.12.1 (#350) Co-authored-by: Renovate Bot <bot@renovateapp.com> * chore: reordered bot section * typo: fixed typo issue from https://github.com/koreanbots/v2-testing/issues/79 * feat: opening new tab for discord link close: https://github.com/koreanbots/v2-testing/issues/99 * feat: added opensearch * Update renovate.json * chore: prevent clickjacking * chore: added moz SearchForm for opensearch xml * fix(deps): update dependency rc-tooltip to v5 (#351) Co-authored-by: Renovate Bot <bot@renovateapp.com> * fix(deps): update sentry monorepo to v6.3.6 (#354) Co-authored-by: Renovate Bot <bot@renovateapp.com> * chore(deps): update dependency prettier to v2.3.0 (#355) Co-authored-by: Renovate Bot <bot@renovateapp.com> * chore(deps): update typescript-eslint monorepo to v4.23.0 (#356) Co-authored-by: Renovate Bot <bot@renovateapp.com> * fix(deps): update dependency postcss to v8.2.15 (#357) Co-authored-by: Renovate Bot <bot@renovateapp.com> * fix(deps): update dependency react-select to v4.3.1 (#358) Co-authored-by: Renovate Bot <bot@renovateapp.com> * fix(deps): update dependency knex to v0.95.5 (#359) Co-authored-by: Renovate Bot <bot@renovateapp.com> * style: added space * feat: added get botSubmits list api * chore: updated endpoint * typo: fixed and improved typo issues * chore: improved message for empty category close: https://github.com/koreanbots/v2-testing/issues/100 * feat: support pwa * types: added missing typing * chore: changed manifest * fix: catching error for ga blocked * fix: added missing argument * chore: made some changes * style: could be null * chore: improved pwa * fix: https://github.com/koreanbots/v2-testing/issues/105 * feat: added staff missing permission * fix: https://github.com/koreanbots/v2-testing/issues/104 * feat: added width style * Update pages/_app.tsx Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * style: suggestions at review * feat: updated api-docs * chore: added dest option * chore: changed icon path * feat: just commiting service worker * feat: added bug bounty group * ci: removed reviewdog * feat: added google optimize * chore: added maskable icon and changed short_name * ci: made some changes on renovate * Create SECURITY.md * feat: fetching docs from github * feat: added tos at footer * feat(iOS): added pwa splash screen * types: improved component typing * feat: discord rebranded * ci: configured renovate ignore * [TYPO] 기여 규칙 링크 수정 (#367) * fix(deps): update sentry monorepo to v6.4.0 (#364) * feat: added logging * style: reordered import * feat: improved logging * feat: private api changes * feat: added OG * chore: updated migrate.sql * ci: updated renovate * fix: seo * feat: added approve api * chore: some changes at deny * feat: added approve * refactor: using next-seo for seo * ci(renovate): removed unused option * chore: not passing pwa at navbar * style: removed line break * fix: https://github.com/koreanbots/v2-testing/issues/89 * feat: directly fetching from discord * feat: support searching with index * style: fix deepscan * fix: invalid avatar url * fix: https://github.com/koreanbots/v2-testing/issues/110 reopen: https://github.com/koreanbots/v2-testing/issues/89 * feat: added error message at submit button * fix: https://github.com/koreanbots/v2-testing/issues/89 * feat: added deny presets article * feat: added query aliases * chore: update docs * chore: remvoed empty file * feat: increased ratelimit * feat: added bot lists * style: removed unused variable * fix(deps): update dependency knex to v0.95.6 (#365) * chore(deps): update typescript-eslint monorepo to v4.24.0 (#366) * chore(deps): update dependency @types/react to v17.0.6 (#368) * fix(deps): update dependency formik to v2.2.8 (#369) * fix(deps): update dependency next to v10.2.2 (#370) * fix(deps): update sentry monorepo to v6.4.1 (#371) * fix(deps): update dependency sanitize-html to v2.4.0 (#372) * fix(deps): update dependency postcss to v8.3.0 (#373) * docs: updated license * feat: added refresh data * feat: better image size close: https://github.com/koreanbots/v2-testing/issues/81 * chore: changed slogan * fix: invalid v1 api * fix: forbidden error * feat: added char count at textarea close: https://github.com/koreanbots/v2-testing/issues/112 * feat: changed edit page route * fix(deps): update dependency next to v10.2.3 (#376) Co-authored-by: Renovate Bot <bot@renovateapp.com> * chore(deps): update dependency typescript to v4.3.2 (#383) Co-authored-by: Renovate Bot <bot@renovateapp.com> * chore(deps): update dependency eslint to v7.27.0 (#374) Co-authored-by: Renovate Bot <bot@renovateapp.com> * deps: removed core-js * deps: lock updated * feat: added stable docker compose file Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Renovate Bot <bot@renovateapp.com> Co-authored-by: zero734kr <zero734kr@gmail.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: MintyU <deathcat@outlook.kr>
This commit is contained in:
parent
4227c11d8e
commit
ae47f741b8
35
.all-contributorsrc
Normal file
35
.all-contributorsrc
Normal file
@ -0,0 +1,35 @@
|
||||
{
|
||||
"files": [
|
||||
"README.md"
|
||||
],
|
||||
"imageSize": 100,
|
||||
"commit": false,
|
||||
badgeTemplate: "",
|
||||
"contributors": [
|
||||
{
|
||||
"login": "wonderlandpark",
|
||||
"name": "Junseo Park",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/31924512?v=4",
|
||||
"profile": "https://wonder.im",
|
||||
"contributions": [
|
||||
"maintenance",
|
||||
"business"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "zero734kr",
|
||||
"name": "zero734kr",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/51540538?v=4",
|
||||
"profile": "https://github.com/zero734kr",
|
||||
"contributions": [
|
||||
"review"
|
||||
]
|
||||
}
|
||||
],
|
||||
"contributorsPerLine": 7,
|
||||
"projectName": "koreanbots",
|
||||
"projectOwner": "koreanbots",
|
||||
"repoType": "github",
|
||||
"repoHost": "https://github.com",
|
||||
"skipCi": true
|
||||
}
|
||||
8
.dockerignore
Normal file
8
.dockerignore
Normal file
@ -0,0 +1,8 @@
|
||||
.next/
|
||||
node_modules/
|
||||
Dockerfile
|
||||
yarn-error.log
|
||||
.dockerignore
|
||||
.git
|
||||
.gitignore
|
||||
.github
|
||||
15
.env.demo.local
Normal file
15
.env.demo.local
Normal file
@ -0,0 +1,15 @@
|
||||
KOREANBOTS_URL=http://localhost:3000
|
||||
MYSQL_HOST=mysql
|
||||
MYSQL_USER=root
|
||||
MYSQL_PASSWORD=YOUSHALLNOTPASS
|
||||
MYSQL_DATABASE=discordbots
|
||||
|
||||
DISCORD_CLIENT_ID=CLIENT_ID
|
||||
DISCORD_CLIENT_SECRET=CLIENT_SECRET
|
||||
DISCORD_SCOPE=SCOPE
|
||||
DISCORD_TOKEN=BOT_TOKEN
|
||||
|
||||
GITHUB_CLIENT_ID=GH_CLIENT_ID
|
||||
GITHUB_CLIENT_SECRET=GH_CLIENT_SECRET
|
||||
|
||||
CSRF_SECRET=CSRF_SECRET
|
||||
41
.eslintrc.js
Normal file
41
.eslintrc.js
Normal file
@ -0,0 +1,41 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: {
|
||||
node: true,
|
||||
es6: true,
|
||||
browser: true,
|
||||
es2021: true,
|
||||
},
|
||||
ignorePatterns: ['node_modules/*', '.next/*', '.out/*', '!.prettierrc.js'],
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:react/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
'plugin:jsx-a11y/recommended',
|
||||
],
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
ecmaVersion: 12,
|
||||
sourceType: 'module',
|
||||
},
|
||||
plugins: ['react', '@typescript-eslint'],
|
||||
rules: {
|
||||
'jsx-quotes': ['error', 'prefer-single'],
|
||||
'react/no-unescaped-entities': 'off',
|
||||
'react/prop-types': 'off',
|
||||
'react/react-in-jsx-scope': 'off',
|
||||
'jsx-a11y/anchor-is-valid': 'off',
|
||||
'jsx-a11y/no-noninteractive-element-interactions': 'off',
|
||||
'jsx-a11y/no-static-element-interactions': 'off',
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||
'@typescript-eslint/no-unused-vars': ['warn'],
|
||||
indent: ['error', 'tab'],
|
||||
quotes: ['error', 'single'],
|
||||
semi: ['error', 'never'],
|
||||
},
|
||||
}
|
||||
78
.github/CONTRIBUTING.md
vendored
Normal file
78
.github/CONTRIBUTING.md
vendored
Normal file
@ -0,0 +1,78 @@
|
||||
# 기여하기
|
||||
|
||||
## 이슈를 등록하기 전에
|
||||
|
||||
### 버그
|
||||
|
||||
먼저 지원하는 기기인지 확인합니다.
|
||||
|
||||
#### 지원하지 않는 기기
|
||||
|
||||
```md
|
||||
- 어떠한 확장프로그램 (AdBlock, Darkmode etc.)
|
||||
- 브라우저: IE, Pre 17 Edge.
|
||||
- Windows 7 이전의 Windows
|
||||
- 10.10 버전 이하의 macOS
|
||||
- 10.0 버전 이하의 iOS
|
||||
- 5.0 버전 이하의 안드로이드
|
||||
- 3.5" 아이폰
|
||||
- 모든 VM
|
||||
- 탈옥 또는 루팅된 기기
|
||||
- 공식 지원 종료된 모든 리눅스 버전
|
||||
- 보안 이슈 (보안과 관련된 문제는 비공개적이게 개발자에게 전달해주세요)
|
||||
- 정식빌드에서는 발생하지 않는 Canary혹은 PTB와 같은 베타 버전의 브라우저/OS에서 발생하는 버그
|
||||
- 이외 개발자가 지원 종료 선언한 모든 플랫폼혹은 기기
|
||||
```
|
||||
|
||||
그 다음 이슈를 등록합니다.
|
||||
[등록하기](https://github.com/koreanbots/koreanbots/issues/new/choose)
|
||||
|
||||
**이슈에서는 빠른 소통을 위해 약자를 사용합니다.**
|
||||
이슈를 보신다면 댓글을 남겨주세요.
|
||||
|
||||
- `CR` **Can Reproduce** 의 약자로 재현 가능한 버그라는 뜻입니다.
|
||||
- `CNR` **Can Not Reproduce** 의 약자로 재현이 불가능하다는 뜻입니다.
|
||||
- `NAB` **Not a Bug** 의 약자로 버그에 해당하지 않는다는 뜻입니다.
|
||||
|
||||
#### 승인과 거부
|
||||
|
||||
버그는 2개의 재현가능 여부에 대한 승인(Approve) 또는 거부(Deny)를 받게되면, 승인과 거부가 결정됩니다.
|
||||
|
||||
##### 승인
|
||||
|
||||
버그가 재현 가능하다고 2명 이상의 유저에게 승인이 된다면, 해당 버그는 개발자의 확인을 기다리며, `approved` 라벨을 획득합니다.
|
||||
|
||||
##### 거부
|
||||
|
||||
버그가 재현 가능하지않다고 2명 이상의 유저에게 거부가 된다면, 해당 버그는 `deny` 라벨을 획득하며, 이슈는 `Closed` 처리됩니다.
|
||||
|
||||
### 제안
|
||||
|
||||
제안은 [Discussions](https://github.com/koreanbots/koreanbots/discussions)에서 자유롭게 해주세요!
|
||||
|
||||
## 관리
|
||||
|
||||
이슈는 관리자와 버그 헌터분들이 관리합니다.
|
||||
|
||||
### 버그 헌터란?
|
||||
|
||||
버그 헌터는 버그를 열심히 찾아주시거나, 해당 레포지토리에 활발하게 참여하여, 특정 기준 이상을 참여해주신 유저분들에게 지급해드리는 권한입니다.
|
||||
|
||||
버그헌터는 이슈를 닫거나, 라벨을 추가할 수 있으며 `Approve`와 `Deny`와 같은 상태를 관리합니다.
|
||||
|
||||
### 처벌
|
||||
|
||||
이슈에서 장난식 발언을 하거나, 관련성이 없는 말 또는 스팸을 게시한다면, 통보없이 처벌되실 수 있습니다.
|
||||
|
||||
## 기여
|
||||
|
||||
기여는 언제든 환영입니다 커밋메세지는 다음 규칙을 따라주시면 감사하겠습니다!
|
||||
|
||||
[Conventional Commits](https://www.conventionalcommits.org/ko/v1.0.0/)
|
||||
|
||||
---
|
||||
|
||||
### 기존 레포들
|
||||
|
||||
- [client](https://github.com/koreanbots/client)
|
||||
- [api](https://github.com/koreanbots/api)
|
||||
@ -1,32 +1,40 @@
|
||||
---
|
||||
name: 🐛 버그 제보
|
||||
name: "\U0001F41B 버그 제보"
|
||||
about: 버그를 제보해주세요!
|
||||
title: "[버그] "
|
||||
labels: 'bug'
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
## 재현방법
|
||||
|
||||
> 어떻게하면 발생시킬 수 있나요?
|
||||
|
||||
## 예상되는 정상적인 동작
|
||||
|
||||
> 정상이라면 어떻게 되야하나요?
|
||||
|
||||
## 발생한 문제
|
||||
|
||||
> 어떤 문제가 발생하나요?
|
||||
|
||||
## 클라이언트 버전
|
||||
|
||||
> 클라이언트 버전을 알려주세요!
|
||||
|
||||
<!--
|
||||
클라이언트 버전을 가져오실 줄 모르신다면 아래 링크를 참고해주세요
|
||||
|
||||
https://github.com/koreanbots/docs/blob/master/version.md
|
||||
-->
|
||||
|
||||
## 사양
|
||||
|
||||
> OS 정보와 브라우저의 버전을 알려주세요!
|
||||
|
||||
## 확인
|
||||
|
||||
- [ ] 중복되는 이슈는 없나요?
|
||||
- [ ] 해당 버그를 다시 발생시킬 수 있나요?
|
||||
|
||||
BIN
.github/assets/koreanbots-en.png
vendored
Normal file
BIN
.github/assets/koreanbots-en.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.5 KiB |
BIN
.github/assets/koreanbots-ko.png
vendored
Normal file
BIN
.github/assets/koreanbots-ko.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
7
.github/renovate.json
vendored
Normal file
7
.github/renovate.json
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"labels": ["meta: dependencies"],
|
||||
"reviewers": ["team:core-developer"],
|
||||
"schedule": ["before 8am"],
|
||||
"extends": ["config:base"],
|
||||
"timezone": "Asia/Seoul"
|
||||
}
|
||||
92
.github/workflows/testing.yml
vendored
Normal file
92
.github/workflows/testing.yml
vendored
Normal file
@ -0,0 +1,92 @@
|
||||
name: CI
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'master'
|
||||
- 'stable'
|
||||
pull_request:
|
||||
branches:
|
||||
- '*'
|
||||
jobs:
|
||||
eslint:
|
||||
name: ESLint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: install node v14
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 14
|
||||
- name: yarn install
|
||||
run: yarn install
|
||||
- name: run eslint
|
||||
run: yarn lint
|
||||
env:
|
||||
CI: true
|
||||
test:
|
||||
name: Run Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: install node v14
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 14
|
||||
- name: yarn install
|
||||
run: yarn install
|
||||
- name: Setup MySQL
|
||||
uses: getong/mariadb-action@v1.1
|
||||
with:
|
||||
mysql database: 'discordbots'
|
||||
mysql root password: 'test'
|
||||
- name: Wait for MySQL
|
||||
run: |
|
||||
while ! mysqladmin ping --host=127.0.0.1 --password=test --silent; do
|
||||
sleep 1
|
||||
done
|
||||
- name: Run Jest
|
||||
run: yarn test
|
||||
- name: Generate RSA Key Pair
|
||||
run: |
|
||||
ssh-keygen -b 2048 -t rsa -f key -q -P ""
|
||||
ssh-keygen -b 2048 -e -m pem -f key -q -P "" > private.key
|
||||
mv key public.pem
|
||||
rm key.pub
|
||||
- name: Setup environments
|
||||
run: |
|
||||
mv .env.demo.local .env.production.local
|
||||
printf 'MARIADB_ROOT_PASSWORD=YOUSHALLNOTPASS\nCOMMIT_HASH=${{ github.sha }}' > .env
|
||||
- name: Create needed files
|
||||
run: echo '{"tester":"DEMO_KEY"}' > secret.json
|
||||
- name: Build
|
||||
run: yarn build
|
||||
env:
|
||||
CI: true
|
||||
|
||||
# docker:
|
||||
# needs:
|
||||
# - eslint
|
||||
# - build
|
||||
# - test
|
||||
# name: Docker Image CI
|
||||
# runs-on: ubuntu-latest
|
||||
# steps:
|
||||
# - uses: actions/checkout@v2
|
||||
# - name: install node v14
|
||||
# uses: actions/setup-node@v1
|
||||
# with:
|
||||
# node-version: 14
|
||||
# - name: Generate RSA Key Pair
|
||||
# run: |
|
||||
# ssh-keygen -b 2048 -t rsa -f key -q -P ""
|
||||
# ssh-keygen -b 2048 -e -m pem -f key -q -P "" > private.key
|
||||
# mv key public.pem
|
||||
# rm key.pub
|
||||
# - name: Setup environments
|
||||
# run: |
|
||||
# mv .env.demo.local .env.production.local
|
||||
# printf 'MARIADB_ROOT_PASSWORD=YOUSHALLNOTPASS\nCOMMIT_HASH=${{ github.sha }}' > .env
|
||||
# - name: Create needed files
|
||||
# run: echo '{"tester":"DEMO_KEY"}' > secret.json
|
||||
# - name: Docker Compose
|
||||
# run: docker-compose up -d
|
||||
46
.gitignore
vendored
Normal file
46
.gitignore
vendored
Normal file
@ -0,0 +1,46 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
*.key
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# docker env file
|
||||
.env
|
||||
|
||||
# local env files
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.*.production.local
|
||||
.env.mysql.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# Prevent commiting lock file.
|
||||
package-lock.json
|
||||
|
||||
# sub module
|
||||
api-docs/
|
||||
4
.gitmodules
vendored
Normal file
4
.gitmodules
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
[submodule "api-docs"]
|
||||
path = api-docs
|
||||
url = https://github.com/koreanbots/api-docs
|
||||
branch = master
|
||||
2
.prettierignore
Normal file
2
.prettierignore
Normal file
@ -0,0 +1,2 @@
|
||||
.next/
|
||||
node_modules/
|
||||
11
.prettierrc.js
Normal file
11
.prettierrc.js
Normal file
@ -0,0 +1,11 @@
|
||||
module.exports = {
|
||||
// Change your rules accordingly to your coding style preferences.
|
||||
// https://prettier.io/docs/en/options.html
|
||||
semi: false,
|
||||
trailingComma: 'es5',
|
||||
singleQuote: true,
|
||||
printWidth: 100,
|
||||
tabWidth: 2,
|
||||
useTabs: true,
|
||||
plugins: ['prettier-plugin-tailwind']
|
||||
}
|
||||
7
.vscode/settings.json
vendored
Normal file
7
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"editor.formatOnSave": false,
|
||||
"editor.tabSize": 2,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": true
|
||||
}
|
||||
}
|
||||
36
Dockerfile
Normal file
36
Dockerfile
Normal file
@ -0,0 +1,36 @@
|
||||
FROM node:14.16-alpine
|
||||
|
||||
# install packages
|
||||
RUN apk update && apk upgrade && \
|
||||
apk add --no-cache bash git openssh python3 py3-pip build-base
|
||||
|
||||
# Create app directory
|
||||
RUN mkdir -p /usr/src/app
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
# Get Argument
|
||||
ARG NEXT_PUBLIC_SENTRY_DSN
|
||||
ARG SENTRY_DSN
|
||||
ARG SENTRY_AUTH_TOKEN
|
||||
|
||||
ENV NEXT_PUBLIC_SENTRY_DSN $NEXT_PUBLIC_SENTRY_DSN
|
||||
ENV SENTRY_DSN $SENTRY_DSN
|
||||
ENV SENTRY_AUTH_TOKEN $SENTRY_AUTH_TOKEN
|
||||
ENV SENTRY_ORG koreanbots
|
||||
ENV SENTRY_PROJECT api
|
||||
# Installing dependencies
|
||||
COPY package*.json /usr/src/app/
|
||||
COPY yarn.lock /usr/src/app/
|
||||
RUN yarn install
|
||||
|
||||
# Copying source files
|
||||
COPY . /usr/src/app
|
||||
|
||||
|
||||
RUN printf "NEXT_PUBLIC_TESTER_KEY=9f9c4a7ae9afeb045fe818ed8b741c70b1d25ec236b189566a0db020c5596441\nNEXT_PUBLIC_COMMIT_HASH=$(git rev-parse HEAD)\nNEXT_PUBLIC_BRANCH=$(git branch | sed -n -e 's/^\* \(.*\)/\1/p')" > .env.local
|
||||
|
||||
# Building app
|
||||
RUN yarn build
|
||||
|
||||
# Running the app
|
||||
CMD yarn start
|
||||
677
LICENSE
Normal file
677
LICENSE
Normal file
@ -0,0 +1,677 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) Koreanbots
|
||||
|
||||
Everyone is permitted to copy and distribute verbatim copies of
|
||||
this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero
|
||||
General Public License is a free, copyleft license for software and other kinds
|
||||
of works, specifically designed to ensure cooperation with the community in the
|
||||
case of network server software.
|
||||
|
||||
The licenses for most software and other
|
||||
practical works are designed to take away your freedom to share and change the
|
||||
works. By contrast, our General Public Licenses are intended to guarantee your
|
||||
freedom to share and change all versions of a program--to make sure it remains
|
||||
free software for all its users.
|
||||
|
||||
When we speak of free software, we are
|
||||
referring to freedom, not price. Our General Public Licenses are designed to make
|
||||
sure that you have the freedom to distribute copies of free software (and charge
|
||||
for them if you wish), that you receive source code or can get it if you want it,
|
||||
that you can change the software or use pieces of it in new free programs, and
|
||||
that you know you can do these things.
|
||||
|
||||
Developers that use our General Public
|
||||
Licenses protect your rights with two steps: (1) assert copyright on the
|
||||
software, and (2) offer you this License which gives you legal permission to
|
||||
copy, distribute and/or modify the software.
|
||||
|
||||
A secondary benefit of defending
|
||||
all users' freedom is that improvements made in alternate versions of the
|
||||
program, if they receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and encouraged by the
|
||||
resulting cooperation. However, in the case of software used on network servers,
|
||||
this result may fail to come about. The GNU General Public License permits making
|
||||
a modified version and letting the public access it on a server without ever
|
||||
releasing its source code to the public.
|
||||
|
||||
The GNU Affero General Public License
|
||||
is designed specifically to ensure that, in such cases, the modified source code
|
||||
becomes available to the community. It requires the operator of a network server
|
||||
to provide the source code of the modified version running there to the users of
|
||||
that server. Therefore, public use of a modified version, on a publicly
|
||||
accessible server, gives the public access to the source code of the modified
|
||||
version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is a
|
||||
different license, not a version of the Affero GPL, but Affero has released a new
|
||||
version of the Affero GPL which permits relicensing under this license.
|
||||
|
||||
The
|
||||
precise terms and conditions for copying, distribution and modification
|
||||
follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to
|
||||
version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means
|
||||
copyright-like laws that apply to other kinds of works, such as semiconductor
|
||||
masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and "recipients" may be
|
||||
individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt
|
||||
all or part of the work in a fashion requiring copyright permission, other than
|
||||
the making of an exact copy. The resulting work is called a "modified version" of
|
||||
the earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work"
|
||||
means either the unmodified Program or a work based on the Program.
|
||||
|
||||
To
|
||||
"propagate" a work means to do anything with it that, without permission, would
|
||||
make you directly or secondarily liable for infringement under applicable
|
||||
copyright law, except executing it on a computer or modifying a private copy.
|
||||
Propagation includes copying, distribution (with or without modification), making
|
||||
available to the public, and in some countries other activities as well.
|
||||
|
||||
To
|
||||
"convey" a work means any kind of propagation that enables other parties to make
|
||||
or receive copies. Mere interaction with a user through a computer network, with
|
||||
no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface
|
||||
displays "Appropriate Legal Notices" to the extent that it includes a convenient
|
||||
and prominently visible feature that (1) displays an appropriate copyright
|
||||
notice, and (2) tells the user that there is no warranty for the work (except to
|
||||
the extent that warranties are provided), that licensees may convey the work
|
||||
under this License, and how to view a copy of this License. If the interface
|
||||
presents a list of user commands or options, such as a menu, a prominent item in
|
||||
the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a
|
||||
work means the preferred form of the work for making modifications to it. "Object
|
||||
code" means any non-source form of a work.
|
||||
|
||||
A "Standard Interface" means an
|
||||
interface that either is an official standard defined by a recognized standards
|
||||
body, or, in the case of interfaces specified for a particular programming
|
||||
language, one that is widely used among developers working in that language.
|
||||
|
||||
|
||||
The "System Libraries" of an executable work include anything, other than the
|
||||
work as a whole, that (a) is included in the normal form of packaging a Major
|
||||
Component, but which is not part of that Major Component, and (b) serves only to
|
||||
enable use of the work with that Major Component, or to implement a Standard
|
||||
Interface for which an implementation is available to the public in source code
|
||||
form. A "Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system (if any) on
|
||||
which the executable work runs, or a compiler used to produce the work, or an
|
||||
object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work
|
||||
in object code form means all the source code needed to generate, install, and
|
||||
(for an executable work) run the object code and to modify the work, including
|
||||
scripts to control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free programs
|
||||
which are used unmodified in performing those activities but which are not part
|
||||
of the work. For example, Corresponding Source includes interface definition
|
||||
files associated with source files for the work, and the source code for shared
|
||||
libraries and dynamically linked subprograms that the work is specifically
|
||||
designed to require, such as by intimate data communication or control flow
|
||||
between those
|
||||
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding
|
||||
Source need not include anything that users can regenerate automatically from
|
||||
other parts of the Corresponding Source.
|
||||
|
||||
The Corresponding Source for a work
|
||||
in source code form is that same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights
|
||||
granted under this License are granted for the term of copyright on the Program,
|
||||
and are irrevocable provided the stated conditions are met. This License
|
||||
explicitly affirms your unlimited permission to run the unmodified Program. The
|
||||
output from running a covered work is covered by this License only if the output,
|
||||
given its content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may
|
||||
make, run and propagate covered works that you do not convey, without conditions
|
||||
so long as your license otherwise remains in force. You may convey covered works
|
||||
to others for the sole purpose of having them make modifications exclusively for
|
||||
you, or provide you with facilities for running those works, provided that you
|
||||
comply with the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works for you
|
||||
must do so exclusively on your behalf, under your direction and control, on terms
|
||||
that prohibit them from making any copies of your copyrighted material outside
|
||||
their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is
|
||||
permitted solely under the conditions stated below. Sublicensing is not allowed;
|
||||
section 10 makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From
|
||||
Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective
|
||||
technological measure under any applicable law fulfilling obligations under
|
||||
article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar
|
||||
laws prohibiting or restricting circumvention of such measures.
|
||||
|
||||
When you
|
||||
convey a covered work, you waive any legal power to forbid circumvention of
|
||||
technological measures to the extent such circumvention is effected by exercising
|
||||
rights under this License with respect to the covered work, and you disclaim any
|
||||
intention to limit operation or modification of the work as a means of enforcing,
|
||||
against the work's users, your or third parties' legal rights to forbid
|
||||
circumvention of technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you receive it, in
|
||||
any medium, provided that you conspicuously and appropriately publish on each
|
||||
copy an appropriate copyright notice; keep intact all notices stating that this
|
||||
License and any non-permissive terms added in accord with section 7 apply to the
|
||||
code; keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any
|
||||
price or no price for each copy that you convey, and you may offer support or
|
||||
warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You
|
||||
may convey a work based on the Program, or the modifications to produce it from
|
||||
the Program, in the form of source code under the terms of section 4, provided
|
||||
that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry
|
||||
prominent notices stating that you modified it, and giving a relevant date.
|
||||
|
||||
|
||||
b) The work must carry prominent notices stating that it is released under this
|
||||
License and any conditions added under section 7. This requirement modifies the
|
||||
requirement in section 4 to "keep intact all notices".
|
||||
|
||||
c) You must license
|
||||
the entire work, as a whole, under this License to anyone who comes into
|
||||
possession of a copy. This License will therefore apply, along with any
|
||||
applicable section 7 additional terms, to the whole of the work, and all its
|
||||
parts, regardless of how they are packaged. This License gives no permission to
|
||||
license the work in any other way, but it does not invalidate such permission if
|
||||
you have separately received it.
|
||||
|
||||
d) If the work has interactive user
|
||||
interfaces, each must display Appropriate Legal Notices; however, if the Program
|
||||
has interactive interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other
|
||||
separate and independent works, which are not by their nature extensions of the
|
||||
covered work, and which are not combined with it such as to form a larger
|
||||
program, in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not used to limit
|
||||
the access or legal rights of the compilation's users beyond what the individual
|
||||
works permit. Inclusion of a covered work in an aggregate does not cause this
|
||||
License to apply to the other parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source
|
||||
Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms of
|
||||
sections 4 and 5, provided that you also convey the machine-readable
|
||||
Corresponding Source under the terms of this License, in one of these ways:
|
||||
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product (including a
|
||||
physical distribution medium), accompanied by the Corresponding Source fixed on a
|
||||
durable physical medium customarily used for software interchange.
|
||||
|
||||
b)
|
||||
Convey the object code in, or embodied in, a physical product (including a
|
||||
physical distribution medium), accompanied by a written offer, valid for at least
|
||||
three years and valid for as long as you offer spare parts or customer support
|
||||
for that product model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the product that is
|
||||
covered by this License, on a durable physical medium customarily used for
|
||||
software interchange, for a price no more than your reasonable cost of physically
|
||||
performing this conveying of source, or (2) access to copy the Corresponding
|
||||
Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of
|
||||
the object code with a copy of the written offer to provide the Corresponding
|
||||
Source. This alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord with
|
||||
subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a
|
||||
designated place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no further charge.
|
||||
You need not require recipients to copy the Corresponding Source along with the
|
||||
object code. If the place to copy the object code is a network server, the
|
||||
Corresponding Source may be on a different server (operated by you or a third
|
||||
party) that supports equivalent copying facilities, provided you maintain clear
|
||||
directions next to the object code saying where to find the Corresponding Source.
|
||||
Regardless of what server hosts the Corresponding Source, you remain obligated to
|
||||
ensure that it is available for as long as needed to satisfy these
|
||||
requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission,
|
||||
provided you inform other peers where the object code and Corresponding Source of
|
||||
the work are being offered to the general public at no charge under subsection
|
||||
6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be included in
|
||||
conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer
|
||||
product", which means any tangible personal property which is normally used for
|
||||
personal, family, or household purposes, or (2) anything designed or sold for
|
||||
incorporation into a dwelling. In determining whether a product is a consumer
|
||||
product, doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a typical or
|
||||
common use of that class of product, regardless of the status of the particular
|
||||
user or of the way in which the particular user actually uses, or expects or is
|
||||
expected to use, the product. A product is a consumer product regardless of
|
||||
whether the product has substantial commercial, industrial or non-consumer uses,
|
||||
unless such uses represent the only significant mode of use of the product.
|
||||
|
||||
|
||||
"Installation Information" for a User Product means any methods, procedures,
|
||||
authorization keys, or other information required to install and execute modified
|
||||
versions of a covered work in that User Product from a modified version of its
|
||||
Corresponding Source. The information must suffice to ensure that the continued
|
||||
functioning of the modified object code is in no case prevented or interfered
|
||||
with solely because modification has been made.
|
||||
|
||||
If you convey an object code
|
||||
work under this section in, or with, or specifically for use in, a User Product,
|
||||
and the conveying occurs as part of a transaction in which the right of
|
||||
possession and use of the User Product is transferred to the recipient in
|
||||
perpetuity or for a fixed term (regardless of how the transaction is
|
||||
characterized), the Corresponding Source conveyed under this section must be
|
||||
accompanied by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install modified object
|
||||
code on the User Product (for example, the work has been installed in ROM).
|
||||
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates for a
|
||||
work that has been modified or installed by the recipient, or for the User
|
||||
Product in which it has been modified or installed. Access to a network may be
|
||||
denied when the modification itself materially and adversely affects the
|
||||
operation of the network or violates the rules and protocols for communication
|
||||
across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation
|
||||
Information provided, in accord with this section must be in a format that is
|
||||
publicly documented (and with an implementation available to the public in source
|
||||
code form), and must require no special password or key for unpacking, reading or
|
||||
copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that
|
||||
supplement the terms of this License by making exceptions from one or more of its
|
||||
conditions. Additional permissions that are applicable to the entire Program
|
||||
shall be treated as though they were included in this License, to the extent that
|
||||
they are valid under applicable law. If additional permissions apply only to part
|
||||
of the Program, that part may be used separately under those permissions, but the
|
||||
entire Program remains governed by this License without regard to the additional
|
||||
permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of it.
|
||||
(Additional permissions may be written to require their own removal in certain
|
||||
cases when you modify the work.) You may place additional permissions on
|
||||
material, added by you to a covered work, for which you have or can give
|
||||
appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this
|
||||
License, for material you add to a covered work, you may (if authorized by the
|
||||
copyright holders of that material) supplement the terms of this License with
|
||||
terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation
|
||||
of specified reasonable legal notices or author attributions in that material or
|
||||
in the Appropriate Legal Notices displayed by works containing it; or
|
||||
|
||||
c)
|
||||
Prohibiting misrepresentation of the origin of that material, or requiring that
|
||||
modified versions of such material be marked in reasonable ways as different from
|
||||
the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of
|
||||
names of licensors or authors of the material; or
|
||||
|
||||
e) Declining to grant
|
||||
rights under trademark law for use of some trade names, trademarks, or service
|
||||
marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of it) with
|
||||
contractual assumptions of liability to the recipient, for any liability that
|
||||
these contractual assumptions directly impose on those licensors and authors.
|
||||
|
||||
|
||||
All other non-permissive additional terms are considered "further restrictions"
|
||||
within the meaning of section 10. If the Program as you received it, or any part
|
||||
of it, contains a notice stating that it is governed by this License along with a
|
||||
term that is a further restriction, you may remove that term. If a license
|
||||
document contains a further restriction but permits relicensing or conveying
|
||||
under this License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does not survive
|
||||
such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord
|
||||
with this section, you must place, in the relevant source files, a statement of
|
||||
the additional terms that apply to those files, or a notice indicating where to
|
||||
find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive,
|
||||
may be stated in the form of a separately written license, or stated as
|
||||
exceptions; the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You
|
||||
may not propagate or modify a covered work except as expressly provided under
|
||||
this License. Any attempt otherwise to propagate or modify it is void, and will
|
||||
automatically terminate your rights under this License (including any patent
|
||||
licenses granted under the third paragraph of section 11).
|
||||
|
||||
However, if you
|
||||
cease all violation of this License, then your license from a particular
|
||||
copyright holder is reinstated (a) provisionally, unless and until the copyright
|
||||
holder explicitly and finally terminates your license, and (b) permanently, if
|
||||
the copyright holder fails to notify you of the violation by some reasonable
|
||||
means prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a
|
||||
particular copyright holder is reinstated permanently if the copyright holder
|
||||
notifies you of the violation by some reasonable means, this is the first time
|
||||
you have received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after your receipt
|
||||
of the notice.
|
||||
|
||||
Termination of your rights under this section does not
|
||||
terminate the licenses of parties who have received copies or rights from you
|
||||
under this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same material
|
||||
under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are
|
||||
not required to accept this License in order to receive or run a copy of the
|
||||
Program. Ancillary propagation of a covered work occurring solely as a
|
||||
consequence of using peer-to-peer transmission to receive a copy likewise does
|
||||
not require acceptance. However, nothing other than this License grants you
|
||||
permission to propagate or modify any covered work. These actions infringe
|
||||
copyright if you do not accept this License. Therefore, by modifying or
|
||||
propagating a covered work, you indicate your acceptance of this License to do
|
||||
so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you
|
||||
convey a covered work, the recipient automatically receives a license from the
|
||||
original licensors, to run, modify and propagate that work, subject to this
|
||||
License. You are not responsible for enforcing compliance by third parties with
|
||||
this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control
|
||||
of an organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered work results
|
||||
from an entity transaction, each party to that transaction who receives a copy of
|
||||
the work also receives whatever licenses to the work the party's predecessor in
|
||||
interest had or could give under the previous paragraph, plus a right to
|
||||
possession of the Corresponding Source of the work from the predecessor in
|
||||
interest, if the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
|
||||
You may not impose any further restrictions on the exercise of the rights granted
|
||||
or affirmed under this License. For example, you may not impose a license fee,
|
||||
royalty, or other charge for exercise of rights granted under this License, and
|
||||
you may not initiate litigation (including a cross-claim or counterclaim in a
|
||||
lawsuit) alleging that any patent claim is infringed by making, using, selling,
|
||||
offering for sale, or importing the Program or any portion of it.
|
||||
|
||||
11.
|
||||
Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The work thus
|
||||
licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's
|
||||
"essential patent claims" are all patent claims owned or controlled by the
|
||||
contributor, whether already acquired or hereafter acquired, that would be
|
||||
infringed by some manner, permitted by this License, of making, using, or selling
|
||||
its contributor version, but do not include claims that would be infringed only
|
||||
as a consequence of further modification of the contributor version. For purposes
|
||||
of this definition, "control" includes the right to grant patent sublicenses in a
|
||||
manner consistent with the requirements of this License.
|
||||
|
||||
Each contributor
|
||||
grants you a non-exclusive, worldwide, royalty-free patent license under the
|
||||
contributor's essential patent claims, to make, use, sell, offer for sale, import
|
||||
and otherwise run, modify and propagate the contents of its contributor
|
||||
version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent (such as an
|
||||
express permission to practice a patent or covenant not to s ue for patent
|
||||
infringement). To "grant" such a patent license to a party means to make such an
|
||||
agreement or commitment not to enforce a patent against the party.
|
||||
|
||||
If you
|
||||
convey a covered work, knowingly relying on a patent license, and the
|
||||
Corresponding Source of the work is not available for anyone to copy, free of
|
||||
charge and under the terms of this License, through a publicly available network
|
||||
server or other readily accessible means, then you must either (1) cause the
|
||||
Corresponding Source to be so available, or (2) arrange to deprive yourself of
|
||||
the benefit of the patent license for this particular work, or (3) arrange, in a
|
||||
manner consistent with the requirements of this License, to extend the patent
|
||||
|
||||
|
||||
license to downstream recipients. "Knowingly relying" means you have actual
|
||||
knowledge that, but for the patent license, your conveying the covered work in a
|
||||
country, or your recipient's use of the covered work in a country, would infringe
|
||||
one or more identifiable patents in that country that you have reason to believe
|
||||
are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a covered work,
|
||||
and grant a patent license to some of the parties receiving the covered work
|
||||
authorizing them to use, propagate, modify or convey a specific copy of the
|
||||
covered work, then the patent license you grant is automatically extended to all
|
||||
recipients of the covered work and works based on it.
|
||||
|
||||
A patent license is
|
||||
"discriminatory" if it does not include within the scope of its coverage,
|
||||
prohibits the exercise of, or is conditioned on the non-exercise of one or more
|
||||
of the rights that are specifically granted under this License. You may not
|
||||
convey a covered work if you are a party to an arrangement with a third party
|
||||
that is in the business of distributing software, under which you make payment to
|
||||
the third party based on the extent of your activity of conveying the work, and
|
||||
under which the third party grants, to any of the parties who would receive the
|
||||
covered work from you, a discriminatory patent license (a) in connection with
|
||||
copies of the covered work conveyed by you (or copies made from those copies), or
|
||||
(b) primarily for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement, or that
|
||||
patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License
|
||||
shall be construed as excluding or limiting any implied license or other defenses
|
||||
to infringement that may otherwise be available to you under applicable patent
|
||||
law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on
|
||||
you (whether by court order, agreement or otherwise) that contradict the
|
||||
conditions of this License, they do not excuse you from the conditions of this
|
||||
License. If you cannot convey a covered work so as to satisfy simultaneously your
|
||||
obligations under this License and any other pertinent obligations, then as a
|
||||
consequence you may
|
||||
|
||||
not convey it at all. For example, if you agree to terms
|
||||
that obligate you to collect a royalty for further conveying from those to whom
|
||||
you convey the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote
|
||||
Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding
|
||||
any other provision of this License, if you modify the Program, your modified
|
||||
version must prominently offer all users interacting with it remotely through a
|
||||
computer network (if your version supports such interaction) an opportunity to
|
||||
receive the Corresponding Source of your version by providing access to the
|
||||
Corresponding Source from a network server at no charge, through some standard or
|
||||
customary means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3 of the
|
||||
GNU General Public License that is incorporated pursuant to the following
|
||||
paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed under version
|
||||
3 of the GNU General Public License into a single combined work, and to convey
|
||||
the resulting work. The terms of this License will continue to apply to the part
|
||||
which is the covered work, but the work with which it is combined will remain
|
||||
governed by version 3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions
|
||||
of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new
|
||||
versions of the GNU Affero General Public License from time to time. Such new
|
||||
versions will be similar in spirit to the present version, but may differ in
|
||||
detail to address new problems or concerns.
|
||||
|
||||
Each version is given a
|
||||
distinguishing version number. If the Program specifies that a certain numbered
|
||||
version of the GNU Affero General Public License "or any later version" applies
|
||||
to it, you have the option of following the terms and conditions either of that
|
||||
numbered version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the GNU Affero
|
||||
General Public License, you may choose any version ever published by the Free
|
||||
Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which
|
||||
future versions of the GNU Affero General Public License can be used, that
|
||||
proxy's public statement of acceptance of a version permanently authorizes you to
|
||||
choose that version for the Program.
|
||||
|
||||
Later license versions may give you
|
||||
additional or different permissions. However, no additional obligations are
|
||||
imposed on any author or copyright holder as a result of your choosing to follow
|
||||
a later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE
|
||||
PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED
|
||||
IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS"
|
||||
WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT
|
||||
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
|
||||
PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE
|
||||
PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY
|
||||
COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS
|
||||
PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL,
|
||||
INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE
|
||||
THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED
|
||||
INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE
|
||||
PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY
|
||||
HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of
|
||||
Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability
|
||||
provided above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates an absolute
|
||||
waiver of all civil liability in connection with the Program, unless a warranty
|
||||
or assumption of liability accompanies a copy of the Program in return for a fee.
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If
|
||||
you develop a new program, and you want it to be of the greatest possible use to
|
||||
the public, the best way to achieve this is to make it free software which
|
||||
everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the
|
||||
following notices to the program. It is safest to attach them to the start of
|
||||
each source file to most effectively state the exclusion of warranty; and each
|
||||
file should have at least the "copyright" line and a pointer to where the full
|
||||
notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what
|
||||
it does.>
|
||||
|
||||
Copyright (C) 2021 <name of author>
|
||||
|
||||
This program is free software:
|
||||
you can redistribute it and/or modify it under the terms of the GNU Affero
|
||||
General Public License as published by the Free Software Foundation, either
|
||||
version 3 of the License, or (at your option) any later version.
|
||||
|
||||
This program is
|
||||
distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
|
||||
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
See the GNU Affero General Public License for more details.
|
||||
|
||||
You should have
|
||||
received a copy of the GNU Affero General Public License along with this program.
|
||||
If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to
|
||||
contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with
|
||||
users remotely through a computer network, you should also make sure that it
|
||||
provides a way for users to get its source. For example, if your program is a web
|
||||
application, its interface could display a "Source" link that leads users to an
|
||||
archive of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the specific
|
||||
requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or
|
||||
school, if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
85
README.en.md
Normal file
85
README.en.md
Normal file
@ -0,0 +1,85 @@
|
||||
<div align="center">
|
||||
<img src="./.github/assets/koreanbots-en.png">
|
||||
</div>
|
||||
|
||||

|
||||

|
||||
[](https://deepscan.io/dashboard#view=project&tid=12468&pid=15503&bid=310734)
|
||||
|
||||
> Korean Discord Bots in one place.
|
||||
|
||||
# SNS
|
||||
|
||||
- [Twitter](https://twitter.com/koreanbots)
|
||||
- [Instagram](https://instagram.com/koreanbots)
|
||||
|
||||
# Contact
|
||||
|
||||
- [Developer Email](mailto:wonderlandpark@callisto.team)
|
||||
- [Discord](https://discord.gg/JEh53MQ)
|
||||
|
||||
# Contributing
|
||||
|
||||
Issues and PRs are always welcomed.
|
||||
|
||||
## Before submitting an Issue
|
||||
|
||||
### Bug
|
||||
|
||||
First, check if the device supports it.
|
||||
|
||||
### Devices not supported
|
||||
|
||||
```md
|
||||
- Any extension program (AdBlock, Darkmode etc.)
|
||||
- Browser: IE, Pre 17 Edge.
|
||||
- Windows prior to Windows 7
|
||||
- MacOS version 10.10 or lower
|
||||
- iOS version 10.0 or lower
|
||||
- Android version 5.0 or lower
|
||||
- 3.5" iPhone
|
||||
- All VMs
|
||||
- Jailbroken or rooted device
|
||||
- All Linux versions that have ended official support
|
||||
- Security issues (Please forward security-related issues to the developer privately)
|
||||
- Bugs that do not occur in the official build, occur in the browser/OS of beta versions such as Canary or PTB
|
||||
- All platforms or devices that other developers have declared end of support
|
||||
```
|
||||
|
||||
Then register the issue.
|
||||
[Submit](https://github.com/koreanbots/koreanbots/issues/new/choose)
|
||||
|
||||
If you see an issue, please leave a comment like these.
|
||||
|
||||
- `CR` Means **Can Reproduce**
|
||||
- `CNR` Means **Can Not Reproduce**
|
||||
- `NAB` Means **Not a Bug**
|
||||
|
||||
### Approval and deny
|
||||
|
||||
When a bug receives two reproducible approvals or denial, the approval and rejection are decided.
|
||||
|
||||
#### Approve
|
||||
|
||||
If a bug is approved by more than two user as reproducible, the bug waits for confirmation from the developer and obtains a `approved` label.
|
||||
|
||||
#### Deny
|
||||
|
||||
If a bug is rejected by more than one user because it is not reproducible, the bug gets a `deny` label and the issue is `Closed`.
|
||||
|
||||
### Suggestions
|
||||
|
||||
Please feel free to make suggestions at [Discussions](https://github.com/koreanbots/koreanbots/discussions)!
|
||||
|
||||
## Submit Pull Request
|
||||
|
||||
Contributions are always welcome. We appreciate your commit messages if you follow the rules below!
|
||||
|
||||
[Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/)
|
||||
|
||||
---
|
||||
|
||||
### Old Repositories
|
||||
|
||||
- [client](https://github.com/koreanbots/client)
|
||||
- [api](https://github.com/koreanbots/api)
|
||||
102
README.md
102
README.md
@ -1,82 +1,46 @@
|
||||
# koreanbots
|
||||
<div align="center">
|
||||
<img src="./.github/assets/koreanbots-ko.png">
|
||||
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
|
||||
<!-- ALL-CONTRIBUTORS-BADGE:END -->
|
||||
</div>
|
||||
|
||||

|
||||

|
||||
[](https://deepscan.io/dashboard#view=project&tid=12468&pid=15503&bid=310734)
|
||||
|
||||
[Introduced in English](./README.en.md)
|
||||
|
||||
> 국내 디스코드봇을 한곳에서.
|
||||
|
||||
## 여긴 뭔가요?
|
||||
# SNS
|
||||
|
||||
이 공간은 버그와 제안을 관리하기 위한 공간입니다.
|
||||
- [Twitter](https://twitter.com/koreanbots)
|
||||
- [Instagram](https://instagram.com/koreanbots)
|
||||
|
||||
## 이슈를 등록하기 전에
|
||||
# 문의
|
||||
|
||||
### 버그
|
||||
- [개발자 이메일](mailto:wonderlandpark@callisto.team)
|
||||
- [디스코드](https://discord.gg/JEh53MQ)
|
||||
|
||||
먼저 지원하는 기기인지 확인합니다.
|
||||
# 기여하기
|
||||
|
||||
#### 지원하지 않는 기기
|
||||
이슈와 PR은 언제든지 환영입니다.
|
||||
|
||||
```md
|
||||
- 어떠한 확장프로그램 (AdBlock, Darkmode etc.)
|
||||
- 브라우저: IE, Pre 17 Edge.
|
||||
- Windows 7 이전의 Windows
|
||||
- 10.10 버전 이하의 macOS
|
||||
- 10.0 버전 이하의 iOS
|
||||
- 5.0 버전 이하의 안드로이드
|
||||
- 3.5" 아이폰
|
||||
- 모든 VM
|
||||
- 탈옥 또는 루팅된 기기
|
||||
- 공식 지원 종료된 모든 리눅스 버전
|
||||
- 보안 이슈 (보안과 관련된 문제는 비공개적이게 개발자에게 전달해주세요)
|
||||
- 정식빌드에서는 발생하지 않는 Canary혹은 PTB와 같은 베타 버전의 브라우저/OS에서 발생하는 버그
|
||||
- 이외 개발자가 지원 종료 선언한 모든 플랫폼혹은 기기
|
||||
```
|
||||
[기여 규칙](./.github/CONTRIBUTING.md)
|
||||
|
||||
그 다음 이슈를 등록합니다.
|
||||
[등록하기](https://github.com/koreanbots/koreanbots/issues/new/choose)
|
||||
## 기여자
|
||||
|
||||
**이슈에서는 빠른 소통을 위해 약자를 사용합니다.**
|
||||
이슈를 보신다면 댓글을 남겨주세요.
|
||||
<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
|
||||
<!-- prettier-ignore-start -->
|
||||
<!-- markdownlint-disable -->
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center"><a href="https://wonder.im"><img src="https://avatars.githubusercontent.com/u/31924512?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Junseo Park</b></sub></a><br /><a href="#maintenance-wonderlandpark" title="Maintenance">🚧</a> <a href="#business-wonderlandpark" title="Business development">💼</a></td>
|
||||
<td align="center"><a href="https://github.com/zero734kr"><img src="https://avatars.githubusercontent.com/u/51540538?v=4?s=100" width="100px;" alt=""/><br /><sub><b>zero734kr</b></sub></a><br /><a href="https://github.com/koreanbots/koreanbots/pulls?q=is%3Apr+reviewed-by%3Azero734kr" title="Reviewed Pull Requests">👀</a></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
- `CR` **Can Reproduce** 의 약자로 재현 가능한 버그라는 뜻입니다.
|
||||
- `CNR` **Can Not Reproduce** 의 약자로 재현이 불가능하다는 뜻입니다.
|
||||
- `NAB` **Not a Bug** 의 약자로 버그에 해당하지 않는다는 뜻입니다.
|
||||
<!-- markdownlint-restore -->
|
||||
<!-- prettier-ignore-end -->
|
||||
|
||||
#### 승인과 거부
|
||||
|
||||
버그는 2개의 재현가능 여부에 대한 승인(Approve) 또는 거부(Deny)를 받게되면, 승인과 거부가 결정됩니다.
|
||||
|
||||
##### 승인
|
||||
|
||||
버그가 재현 가능하다고 2명 이상의 유저에게 승인이 된다면, 해당 버그는 개발자의 확인을 기다리며, `approved` 라벨을 획득합니다.
|
||||
|
||||
##### 거부
|
||||
|
||||
버그가 재현 가능하지않다고 2명 이상의 유저에게 거부가 된다면, 해당 버그는 `deny` 라벨을 획득하며, 이슈는 `Closed` 처리됩니다.
|
||||
|
||||
### 제안
|
||||
|
||||
제안은 자유롭게 해주셔도 됩니다!
|
||||
|
||||
## 관리
|
||||
|
||||
이슈는 관리자와 버그 헌터분들이 관리합니다.
|
||||
|
||||
### 버그 헌터란?
|
||||
|
||||
버그 헌터는 버그를 열심히 찾아주시거나, 해당 레포지토리에 활발하게 참여하여, 특정 기준 이상을 참여해주신 유저분들에게 지급해드리는 권한입니다.
|
||||
|
||||
버그헌터는 이슈를 닫거나, 라벨을 추가할 수 있으며 `Approve`와 `Deny`와 같은 상태를 관리합니다.
|
||||
|
||||
### 처벌
|
||||
|
||||
이슈에서 장난식 발언을 하거나, 관련성이 없는 말 또는 스팸을 게시한다면, 통보없이 처벌되실 수 있습니다.
|
||||
|
||||
## 사이트 레포들
|
||||
|
||||
- [client](https://github.com/koreanbots/client)
|
||||
- [api](https://github.com/koreanbots/api)
|
||||
|
||||
### 기여
|
||||
|
||||
기여는 언제든 환영입니다 커밋메세지는 다음 규칙을 따라주시면 감사하겠습니다!
|
||||
|
||||
[Conventional Commits](https://www.conventionalcommits.org/ko/v1.0.0/)
|
||||
<!-- ALL-CONTRIBUTORS-LIST:END -->
|
||||
|
||||
9
SECURITY.md
Normal file
9
SECURITY.md
Normal file
@ -0,0 +1,9 @@
|
||||
# Security Policy
|
||||
|
||||
## Korean
|
||||
|
||||
[버그 바운티 프로그램](https://beta.koreanbots.dev/security)
|
||||
|
||||
## English
|
||||
|
||||
Please [mail](mailto:koreanbots.dev@gmail.com) us!
|
||||
1
api-docs
Submodule
1
api-docs
Submodule
@ -0,0 +1 @@
|
||||
Subproject commit effc22ea2b191f7cfd35e08a4545bc7879e61d24
|
||||
192
app.css
Normal file
192
app.css
Normal file
@ -0,0 +1,192 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
min-height: 100%;
|
||||
overflow-x: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
body * {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Uni Sans Heavy CAPS';
|
||||
src: url('/logofont.otf');
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
.logofont {
|
||||
font-family: 'Uni Sans Heavy CAPS';
|
||||
}
|
||||
|
||||
.animation-dropdown {
|
||||
animation: dropdown 0.1s linear;
|
||||
}
|
||||
|
||||
.iu-is-the-best {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.__control--is-focused {
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
i {
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
/* html * ::-webkit-scrollbar {
|
||||
-webkit-appearance: none;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
html * ::-webkit-scrollbar-thumb {
|
||||
cursor: pointer;
|
||||
border-radius: 5px;
|
||||
background: #ccc;
|
||||
-webkit-transition: color 0.2s ease;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
html .dark * ::-webkit-scrollbar-thumb {
|
||||
cursor: pointer;
|
||||
border-radius: 5px;
|
||||
background: #202225;
|
||||
-webkit-transition: color 0.2s ease;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
html * ::-webkit-scrollbar-track {
|
||||
background: #f2f2f2;
|
||||
border-radius: 0;
|
||||
border: 4px solid transparent;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
html .dark * ::-webkit-scrollbar-track {
|
||||
background: #2e3338;
|
||||
border-radius: 0;
|
||||
} */
|
||||
|
||||
.dark .__multi-value,
|
||||
.dark .__multi-value__label,
|
||||
.dark .__multi-value__remove {
|
||||
background: #2e3338 !important;
|
||||
}
|
||||
|
||||
.scroll-none {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.scroll-none ::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
button {
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
.emoji-selector-button {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: inline-block;
|
||||
background-image: url('https://unpkg.com/emoji-datasource-twitter@5.0.1/img/twitter/sheets-256/64.png');
|
||||
background-size: 5700% 5700%;
|
||||
background-position: 53.5714% 62.5%;
|
||||
filter: grayscale(100%);
|
||||
}
|
||||
|
||||
.emoji-selector-button:hover {
|
||||
filter: grayscale(0%);
|
||||
transform: scale(1.1, 1.1);
|
||||
opacity: 90%;
|
||||
transition: ease-in 100ms;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.emoji-mart-category-list > *,
|
||||
.emoji-mart-emoji > span {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cursor-heart {
|
||||
cursor: url("/img/heart.svg"), auto;
|
||||
}
|
||||
|
||||
/* NProgress */
|
||||
|
||||
#nprogress {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#nprogress .bar {
|
||||
background: #3366FF;
|
||||
|
||||
position: fixed;
|
||||
z-index: 1031;
|
||||
top: 0;
|
||||
left: 0;
|
||||
|
||||
width: 100%;
|
||||
height: 1.5px;
|
||||
}
|
||||
|
||||
/* Fancy blur effect */
|
||||
#nprogress .peg {
|
||||
display: block;
|
||||
position: absolute;
|
||||
right: 0px;
|
||||
width: 100px;
|
||||
height: 100%;
|
||||
box-shadow: 0 0 10px #3366FF, 0 0 5px #3366FF;
|
||||
opacity: 1.0;
|
||||
transform: rotate(3deg) translate(0px, -4px);
|
||||
}
|
||||
|
||||
/* Remove these to get rid of the spinner */
|
||||
#nprogress .spinner {
|
||||
display: block;
|
||||
position: fixed;
|
||||
z-index: 1031;
|
||||
top: 15px;
|
||||
right: 15px;
|
||||
}
|
||||
|
||||
#nprogress .spinner-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
box-sizing: border-box;
|
||||
|
||||
border: solid 2px transparent;
|
||||
border-top-color: #3366FF;
|
||||
border-left-color: #3366FF;
|
||||
border-radius: 50%;
|
||||
animation: nprogress-spinner 400ms linear infinite;
|
||||
}
|
||||
|
||||
.nprogress-custom-parent {
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nprogress-custom-parent #nprogress .spinner,
|
||||
.nprogress-custom-parent #nprogress .bar {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
@keyframes nprogress-spinner {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
49
components/Advertisement.tsx
Normal file
49
components/Advertisement.tsx
Normal file
@ -0,0 +1,49 @@
|
||||
import { useEffect } from 'react'
|
||||
import Logger from '@utils/Logger'
|
||||
|
||||
const Advertisement: React.FC<AdvertisementProps> = ({ size = 'short' }) => {
|
||||
useEffect(() => {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
window.adsbygoogle = window.adsbygoogle || []
|
||||
window.adsbygoogle.push({})
|
||||
}
|
||||
Logger.debug('Ads Pushed')
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`z-0 mx-auto w-full text-center text-white ${
|
||||
process.env.NODE_ENV === 'production' ? '' : 'py-12 bg-gray-700'
|
||||
}`}
|
||||
style={size === 'short' ? { height: '90px' } : { height: '330px' }}
|
||||
>
|
||||
{process.env.NODE_ENV === 'production' ? (
|
||||
<ins
|
||||
className='adsbygoogle w-full'
|
||||
style={{ display: 'inline-block', height: '90px' }}
|
||||
data-ad-client='ca-pub-4856582423981759'
|
||||
data-ad-slot='3250141451'
|
||||
data-adtest='on'
|
||||
data-full-width-responsive='true'
|
||||
></ins>
|
||||
) : (
|
||||
'Advertisement'
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
adsbygoogle: {
|
||||
loaded?: boolean
|
||||
push(obj: unknown): void
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface AdvertisementProps {
|
||||
size?: 'short' | 'tall'
|
||||
}
|
||||
|
||||
export default Advertisement
|
||||
22
components/Application.tsx
Normal file
22
components/Application.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import dynamic from 'next/dynamic'
|
||||
import Link from 'next/link'
|
||||
|
||||
const DiscordAvatar = dynamic(() => import('@components/DiscordAvatar'))
|
||||
|
||||
const Application: React.FC<ApplicationProps> = ({ type, id, name }) => {
|
||||
return <Link href={`/developers/applications/${type + 's'}/${id}`}>
|
||||
<div className='relative px-2 py-4 text-center dark:bg-discord-black bg-little-white rounded-lg cursor-pointer transform hover:-translate-y-1 transition duration-100 ease-in'>
|
||||
<DiscordAvatar userID={id} className='px-2 w-full rounded-xl' />
|
||||
<h2 className='pt-2 whitespace-nowrap text-xl font-medium truncate'>{name}</h2>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
}
|
||||
|
||||
interface ApplicationProps {
|
||||
type: 'bot'
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export default Application
|
||||
124
components/BotCard.tsx
Normal file
124
components/BotCard.tsx
Normal file
@ -0,0 +1,124 @@
|
||||
import Link from 'next/link'
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
import { checkBotFlag, formatNumber, makeBotURL } from '@utils/Tools'
|
||||
import { Status } from '@utils/Constants'
|
||||
import { Bot } from '@types'
|
||||
|
||||
const Divider = dynamic(() => import('@components/Divider'))
|
||||
const Tag = dynamic(() => import('@components/Tag'))
|
||||
const DiscordAvatar = dynamic(() => import('@components/DiscordAvatar'))
|
||||
|
||||
const BotCard: React.FC<BotCardProps> = ({ manage = false, bot }) => {
|
||||
return <div className='container mb-16 transform hover:-translate-y-1 transition duration-100 ease-in cursor-pointer'>
|
||||
<div className='relative'>
|
||||
<div className='container mx-auto'>
|
||||
<div className='h-full'>
|
||||
<div
|
||||
className='relative mx-auto h-full text-black dark:text-white dark:bg-discord-black bg-little-white rounded-2xl shadow-xl'
|
||||
style={
|
||||
checkBotFlag(bot.flags, 'trusted') && bot.banner
|
||||
? {
|
||||
background: `linear-gradient(to right, rgba(34, 36, 38, 0.68), rgba(34, 36, 38, 0.68)), url("${bot.banner}") center top / cover no-repeat`,
|
||||
color: 'white',
|
||||
}
|
||||
: {}
|
||||
}
|
||||
>
|
||||
<Link href={makeBotURL(bot)}>
|
||||
<div>
|
||||
<div className='flex h-44'>
|
||||
<div className='w-3/5'>
|
||||
<div className='flex justify-start'>
|
||||
<DiscordAvatar
|
||||
size={128}
|
||||
userID={bot.id}
|
||||
alt='Avatar'
|
||||
className='absolute -left-2 -top-8 mx-auto w-32 h-32 bg-white rounded-full'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='mt-28 px-4'>
|
||||
<h2 className='px-1 text-sm'>
|
||||
<i className={`fas fa-circle text-${Status[bot.status]?.color}`} />
|
||||
{Status[bot.status]?.text}
|
||||
</h2>
|
||||
<h1 className='mb-3 text-left text-2xl font-bold truncate'>{bot.name}</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className='grid grid-cols-1 pr-5 py-5 w-2/5 h-0'>
|
||||
<Tag
|
||||
text={
|
||||
<>
|
||||
<i className='fas fa-heart text-red-600' /> {formatNumber(bot.votes)}
|
||||
</>
|
||||
}
|
||||
dark
|
||||
/>
|
||||
<Tag
|
||||
blurple
|
||||
text={bot.servers ? <>{formatNumber(bot.servers)} 서버</> : 'N/A'}
|
||||
dark
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className='mb-10 px-4 h-6 text-left text-gray-400 text-sm font-medium'>
|
||||
{bot.intro}
|
||||
</p>
|
||||
<div>
|
||||
<div className='category flex flex-wrap px-2'>
|
||||
{bot.category.slice(0, 3).map(el => (
|
||||
<Tag key={el} text={el} href={`/categories/${el}`} dark />
|
||||
))}{' '}
|
||||
{bot.category.length > 3 && <Tag text={`+${bot.category.length - 3}`} dark />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<Divider />
|
||||
<div className='w-full'>
|
||||
<div className='flex justify-evenly'>
|
||||
<Link href={makeBotURL(bot)}>
|
||||
<a className='py-3 w-full text-center text-koreanbots-blue hover:text-white text-sm font-bold hover:bg-koreanbots-blue rounded-bl-2xl hover:shadow-lg transition duration-100 ease-in'>
|
||||
보기
|
||||
</a>
|
||||
</Link>
|
||||
{manage ? (
|
||||
<Link href={`/manage/${bot.id}`}>
|
||||
<a className='py-3 w-full text-center text-green-500 hover:text-white text-sm font-bold hover:bg-green-500 rounded-br-2xl hover:shadow-lg transition duration-100 ease-in'>
|
||||
관리하기
|
||||
</a>
|
||||
</Link>
|
||||
) : bot.state !== 'ok' ? <a
|
||||
className='py-3 w-full text-center text-discord-blurple text-sm font-bold rounded-br-2xl hover:shadow-lg transition duration-100 ease-in opacity-50 cursor-default select-none'
|
||||
>
|
||||
초대하기
|
||||
</a> :
|
||||
<a
|
||||
href={
|
||||
bot.url ||
|
||||
`https://discordapp.com/oauth2/authorize?client_id=${bot.id}&scope=bot&permissions=0`
|
||||
}
|
||||
rel='noopener noreferrer'
|
||||
target='_blank'
|
||||
className='py-3 w-full text-center text-discord-blurple hover:text-white text-sm font-bold hover:bg-discord-blurple rounded-br-2xl hover:shadow-lg transition duration-100 ease-in'
|
||||
>
|
||||
초대하기
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
}
|
||||
|
||||
interface BotCardProps {
|
||||
manage?: boolean
|
||||
bot: Bot
|
||||
}
|
||||
|
||||
export default BotCard
|
||||
47
components/Button.tsx
Normal file
47
components/Button.tsx
Normal file
@ -0,0 +1,47 @@
|
||||
import Link from 'next/link'
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
const Button: React.FC<ButtonProps> = ({
|
||||
type = 'button',
|
||||
className,
|
||||
children,
|
||||
href,
|
||||
disabled=false,
|
||||
onClick,
|
||||
}) => {
|
||||
return href ? <Link href={!disabled && href}>
|
||||
<a
|
||||
className={`cursor-pointer rounded-md px-4 py-2 transition duration-300 ease select-none outline-none foucs:outline-none mr-1.5 ${className ??
|
||||
'bg-discord-blurple hover:opacity-80 dark:bg-very-black dark:hover:bg-discord-dark-hover text-white'}`}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
</Link>
|
||||
: onClick ? <button
|
||||
type={disabled ? 'button' : type}
|
||||
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 ??
|
||||
'bg-discord-blurple hover:opacity-80 dark:bg-very-black dark:hover:bg-discord-dark-hover text-white'}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
:
|
||||
<button
|
||||
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 ??
|
||||
'bg-discord-blurple hover:opacity-80 dark:bg-very-black dark:hover:bg-discord-dark-hover text-white'}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
}
|
||||
|
||||
interface ButtonProps {
|
||||
type?: 'button' | 'submit' | 'reset'
|
||||
className?: string
|
||||
children: ReactNode
|
||||
href?: string
|
||||
disabled?: boolean
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
export default Button
|
||||
15
components/Captcha.tsx
Normal file
15
components/Captcha.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import { Ref } from 'react'
|
||||
import HCaptcha from '@hcaptcha/react-hcaptcha'
|
||||
|
||||
|
||||
const Captcha: React.FC<CaptchaProps> = ({ dark, onVerify }) => {
|
||||
return <HCaptcha sitekey='43e556b4-cc90-494f-b100-378b906bb736' theme={dark ? 'dark' : 'light'} onVerify={onVerify}/>
|
||||
}
|
||||
|
||||
interface CaptchaProps {
|
||||
dark: boolean
|
||||
onVerify(token: string, eKey?: string): void
|
||||
ref?: Ref<HCaptcha>
|
||||
}
|
||||
|
||||
export default Captcha
|
||||
21
components/ColorCard.tsx
Normal file
21
components/ColorCard.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
const ColorCard: React.FC<ColorCardProps> = ({ header, first, second, className }) => {
|
||||
return (
|
||||
<div className={`rounded-lg p-10 ${className} shadow-lg`}>
|
||||
<h2 className='text-2xl font-bold'>{header}</h2>
|
||||
<p className='opacity-80'>
|
||||
{first}
|
||||
<br />
|
||||
{second}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ColorCardProps {
|
||||
header: string
|
||||
first: string
|
||||
second: string
|
||||
className: string
|
||||
}
|
||||
|
||||
export default ColorCard
|
||||
27
components/Container.tsx
Normal file
27
components/Container.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
const Container: React.FC<ContainerProps> = ({
|
||||
ignoreColor,
|
||||
className,
|
||||
paddingTop = false,
|
||||
children,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={`${ignoreColor ? '' : 'text-black dark:text-gray-100'} ${
|
||||
paddingTop ? 'pt-20' : ''
|
||||
}`}
|
||||
>
|
||||
<div className={`container mx-auto px-4 ${className}`}>{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ContainerProps {
|
||||
ignoreColor?: boolean
|
||||
className?: string
|
||||
paddingTop?: boolean
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export default Container
|
||||
104
components/DeveloperLayout.tsx
Normal file
104
components/DeveloperLayout.tsx
Normal file
@ -0,0 +1,104 @@
|
||||
/* eslint-disable jsx-a11y/click-events-have-key-events */
|
||||
import dynamic from 'next/dynamic'
|
||||
import Link from 'next/link'
|
||||
import { ReactNode, useState } from 'react'
|
||||
|
||||
import { DocsData } from '@types'
|
||||
import { NextSeo } from 'next-seo'
|
||||
|
||||
const Container = dynamic(() => import('@components/Container'))
|
||||
const Divider = dynamic(() => import('@components/Divider'))
|
||||
|
||||
const DeveloperLayout: React.FC<DeveloperLayout> = ({ children, enabled, docs, currentDoc }:DeveloperLayout) => {
|
||||
const [ navbarEnabled, setNavbarOpen ] = useState(false)
|
||||
|
||||
return <div className='flex min-h-screen'>
|
||||
<NextSeo title='한디리 개발자' description='한국 디스코드봇 리스트 API를 활용하여 봇에 다양한 기능을 추가해보세요.' />
|
||||
<div className='block lg:hidden h-screen relative'>
|
||||
<div className='w-18 pt-20 px-2 h-full text-center bg-little-white dark:bg-discord-black fixed'>
|
||||
<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'}`}>
|
||||
<Link href='/developers/applications'><i className='fas fa-robot'/></Link>
|
||||
</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'}`}>
|
||||
<Link href='/developers/docs'><i className='fas fa-book'/></Link>
|
||||
</li>
|
||||
{
|
||||
enabled === 'docs' && <>
|
||||
<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)}>
|
||||
<i className='fas fa-bars'/>
|
||||
</li></>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`${navbarEnabled ? 'block' : 'hidden'} lg:block relative`}>
|
||||
<div className='bg-little-white dark:bg-discord-black pt-20 px-6 fixed h-screen w-screen lg:w-60 overflow-y-auto'>
|
||||
<ul className='text-base text-gray-600 dark:text-gray-300 mb-6 hidden 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>
|
||||
<Divider className='lg:hidden' />
|
||||
<Link href='/developers/applications'>
|
||||
<li className={`cursor-pointer py-2 px-4 rounded-md ${enabled === 'applications' ? 'bg-discord-blurple text-white' : 'hover:text-gray-500 dark:hover:text-white'}`}>
|
||||
나의 봇
|
||||
</li>
|
||||
</Link>
|
||||
<Link href='/developers/docs'>
|
||||
<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>
|
||||
</Link>
|
||||
</ul>
|
||||
{
|
||||
enabled === 'docs' && <>
|
||||
<Divider className='hidden lg:block' />
|
||||
<ul className='text-sm text-gray-600 dark:text-gray-300 px-0.5 lg:mt-6'>
|
||||
<li onClick={() => setNavbarOpen(false)} className='lg:hidden cursor-pointer py-1 px-4 rounded-md mb-2'>
|
||||
<i className='fas fa-times' /> 닫기
|
||||
</li>
|
||||
<Divider className='lg:hidden' />
|
||||
{
|
||||
docs?.map(el => {
|
||||
if(el.list) return <div key={el.name} className='mt-2'>
|
||||
<span className='text-gray-600 dark:text-gray-100 font-bold mb-1'>{el.name}</span>
|
||||
<ul className='text-sm py-3'>
|
||||
{
|
||||
el.list.map(e =>
|
||||
<Link key={e.name} 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'}`}>
|
||||
{e.name}
|
||||
</li>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
return <Link key={el.name} href={`/developers/docs/${el.name}`}>
|
||||
<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'}`}>
|
||||
{el.name}
|
||||
</li>
|
||||
</Link>
|
||||
})
|
||||
}
|
||||
</ul>
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div className='w-full py-28 lg:pl-60 pl-16'>
|
||||
<Container>
|
||||
{children}
|
||||
</Container>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
interface DeveloperLayout {
|
||||
children: ReactNode
|
||||
enabled: 'applications' | 'docs'
|
||||
docs?: DocsData[]
|
||||
currentDoc?: string
|
||||
}
|
||||
|
||||
export default DeveloperLayout
|
||||
60
components/DiscordAvatar.tsx
Normal file
60
components/DiscordAvatar.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
import { SyntheticEvent, useEffect, useState } from 'react'
|
||||
import { KoreanbotsEndPoints } from '@utils/Constants'
|
||||
import { supportsWebP } from '@utils/Tools'
|
||||
import Logger from '@utils/Logger'
|
||||
|
||||
const DiscordAvatar: React.FC<DiscordAvatarProps> = props => {
|
||||
const fallback = '/img/default.png'
|
||||
const [ webpUnavailable, setWebpUnavailable ] = useState<boolean>()
|
||||
|
||||
useEffect(()=> {
|
||||
setWebpUnavailable(localStorage.webp === 'false')
|
||||
}, [])
|
||||
|
||||
return <img
|
||||
alt={props.alt ?? 'Image'}
|
||||
loading='lazy'
|
||||
className={props.className}
|
||||
src={
|
||||
KoreanbotsEndPoints.CDN.avatar(props.userID, { format: !webpUnavailable ? 'webp' : 'png', size: props.size ?? 256})
|
||||
}
|
||||
onError={(e: SyntheticEvent<HTMLImageElement, ImageEvent>)=> {
|
||||
if(webpUnavailable) {
|
||||
(e.target as ImageTarget).onerror = (event) => {
|
||||
// All Fails
|
||||
(event.target as ImageTarget).onerror = ()=> { Logger.warn('FALLBACK IMAGE LOAD FAIL') }
|
||||
(event.target as ImageTarget).src = fallback
|
||||
|
||||
}
|
||||
}
|
||||
else {
|
||||
(e.target as ImageTarget).onerror = (event) => {
|
||||
// All Fails
|
||||
(event.target as ImageTarget).onerror = ()=> { Logger.warn('FALLBACK IMAGE LOAD FAIL') }
|
||||
(event.target as ImageTarget).src = fallback
|
||||
}
|
||||
// Webp Load Fail
|
||||
(e.target as ImageTarget).src = KoreanbotsEndPoints.CDN.avatar(props.userID, { size: props.size ?? 256})
|
||||
if(!supportsWebP()) localStorage.setItem('webp', 'false')
|
||||
}
|
||||
}}
|
||||
/>
|
||||
}
|
||||
|
||||
interface DiscordAvatarProps {
|
||||
alt?: string
|
||||
userID: string
|
||||
className?: string
|
||||
size? : 128 | 256 | 512
|
||||
}
|
||||
|
||||
interface ImageEvent extends Event {
|
||||
target: ImageTarget
|
||||
}
|
||||
|
||||
interface ImageTarget extends EventTarget {
|
||||
src: string
|
||||
onerror: (event: SyntheticEvent<HTMLImageElement, ImageEvent>) => void
|
||||
}
|
||||
|
||||
export default DiscordAvatar
|
||||
17
components/Divider.tsx
Normal file
17
components/Divider.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
const Divider: React.FC<DividerProps> = ({ className }) => {
|
||||
return (
|
||||
<div
|
||||
className={`my-2 px-5 ${className || ''}`}
|
||||
style={{
|
||||
borderTop: '1px solid rgba(34,36,38,.15)',
|
||||
borderBottom: '1px solid hsla(0,0%,100%,.1)',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
interface DividerProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default Divider
|
||||
38
components/Docs.tsx
Normal file
38
components/Docs.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
import { NextSeo } from 'next-seo'
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
const Container = dynamic(() => import('@components/Container'))
|
||||
|
||||
const Docs: React.FC<DocsProps> = ({ title, header, description, subheader, children }) => {
|
||||
return (
|
||||
<>
|
||||
<NextSeo title={typeof header === 'string' ? header : title} description={description || subheader}/>
|
||||
<div className='dark:bg-discord-black bg-discord-blurple z-20'>
|
||||
<Container className='py-20' ignoreColor>
|
||||
<h1 className='mt-10 text-center text-gray-100 text-4xl font-bold sm:text-left'>
|
||||
{header}
|
||||
</h1>
|
||||
<h2 className='mt-5 text-center text-gray-200 text-xl font-medium sm:text-left'>
|
||||
{description}
|
||||
</h2>
|
||||
<h2 className='mt-5 text-center text-gray-200 text-xl font-medium sm:text-left'>
|
||||
{subheader}
|
||||
</h2>
|
||||
</Container>
|
||||
</div>
|
||||
<Container className='pt-10 pb-20'>
|
||||
<div>{children}</div>
|
||||
</Container>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Docs
|
||||
|
||||
interface DocsProps {
|
||||
header: string | React.ReactNode
|
||||
title?: string
|
||||
description?: string
|
||||
subheader?: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
118
components/Footer.tsx
Normal file
118
components/Footer.tsx
Normal file
@ -0,0 +1,118 @@
|
||||
import Link from 'next/link'
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
import { Theme } from '@types'
|
||||
|
||||
const Container = dynamic(() => import('@components/Container'))
|
||||
const Toggle = dynamic(() => import('@components/Toggle'))
|
||||
|
||||
const Footer: React.FC<FooterProps> = ({ theme, setTheme }) => {
|
||||
return (
|
||||
<div className='releative z-30'>
|
||||
<div className='bottom-0 text-white bg-discord-black py-24'>
|
||||
<Container className='w-11/12 lg:flex lg:pt-0 lg:w-4/5' ignoreColor>
|
||||
<div className='w-full lg:w-2/5'>
|
||||
<h1 className='text-koreanbots-blue text-3xl font-bold'>국내 디스코드 봇을 한 곳에서.</h1>
|
||||
<span className='text-base'>2020-2021 Koreanbots, All rights reserved.</span>
|
||||
<div className='text-2xl'>
|
||||
<Link href='/discord'>
|
||||
<a className='mr-2'>
|
||||
<i className='fab fa-discord' />
|
||||
</a>
|
||||
</Link>
|
||||
<a href='https://github.com/koreanbots' className='mr-2'>
|
||||
<i className='fab fa-github' />
|
||||
</a>
|
||||
<a href='https://twitter.com/koreanbots' className='mr-2'>
|
||||
<i className='fab fa-twitter' />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className='grid flex-grow gap-2 grid-cols-2 md:grid-cols-7'>
|
||||
<div className='col-span-2 mb-2'>
|
||||
<h2 className='text-koreanbots-blue text-base font-bold'>한국 디스코드봇 리스트</h2>
|
||||
<ul className='text-sm'>
|
||||
<li>
|
||||
<Link href='/about'>
|
||||
<a className='hover:text-gray-300'>소개</a>
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href='/developers'>
|
||||
<a className='hover:text-gray-300'>개발자</a>
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href='/security'>
|
||||
<a className='hover:text-gray-300'>버그 바운티</a>
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className='col-span-2 mb-2'>
|
||||
<h2 className='text-koreanbots-blue text-base font-bold'>정책</h2>
|
||||
<ul className='text-sm'>
|
||||
<li>
|
||||
<Link href='/tos'>
|
||||
<a className='hover:text-gray-300'>서비스 이용약관</a>
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href='/privacy'>
|
||||
<a className='hover:text-gray-300'>개인정보취급방침</a>
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href='/guidelines'>
|
||||
<a className='hover:text-gray-300'>가이드라인</a>
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href='/license'>
|
||||
<a className='hover:text-gray-300'>라이선스</a>
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className='col-span-1 mb-2'>
|
||||
<h2 className='text-koreanbots-blue text-base font-bold'>커뮤니티</h2>
|
||||
<ul className='text-sm'>
|
||||
{/* <li>
|
||||
<Link href='/partners'>
|
||||
<a className='hover:text-gray-300'>파트너</a>
|
||||
</Link>
|
||||
</li> */}
|
||||
<li>
|
||||
<Link href='/verification'>
|
||||
<a className='hover:text-gray-300'>인증</a>
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className='col-span-2 mb-2'>
|
||||
<h2 className='text-koreanbots-blue text-base font-bold'>기타</h2>
|
||||
<div className='flex'>
|
||||
<a className='mr-2 hover:text-gray-300'>다크모드</a>
|
||||
<Toggle
|
||||
checked={theme === 'dark'}
|
||||
onChange={() => {
|
||||
const t = theme === 'dark' ? 'light' : 'dark'
|
||||
setTheme(t)
|
||||
localStorage.setItem('theme', t)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface FooterProps {
|
||||
theme: Theme
|
||||
setTheme(value: Theme): void
|
||||
}
|
||||
|
||||
export default Footer
|
||||
26
components/Forbidden.tsx
Normal file
26
components/Forbidden.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import dynamic from 'next/dynamic'
|
||||
import { useRouter } from 'next/router'
|
||||
import { NextSeo } from 'next-seo'
|
||||
|
||||
import { ErrorText } from '@utils/Constants'
|
||||
|
||||
const Button = dynamic(() => import('@components/Button'))
|
||||
|
||||
const Forbidden:React.FC = () => {
|
||||
const router = useRouter()
|
||||
return <>
|
||||
<NextSeo title='권한이 없습니다' />
|
||||
<div className='flex items-center justify-center h-screen select-none'>
|
||||
<div className='container mx-auto px-20 md:text-left text-center'>
|
||||
<h1 className='text-8xl font-semibold'>403</h1>
|
||||
<h2 className='text-2xl font-semibold py-2'>
|
||||
{ErrorText[403]}
|
||||
</h2>
|
||||
<Button onClick={router.back}>뒤로 가기</Button>
|
||||
<p className='text-gray-400 text-sm mt-2'>해당 작업을 수행할 수 있는 권한이 없습니다. 무언가 잘못된 것 같다면 문의해주세요.</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
|
||||
export default Forbidden
|
||||
12
components/Form/CheckBox.tsx
Normal file
12
components/Form/CheckBox.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import { Field } from 'formik'
|
||||
|
||||
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} />
|
||||
}
|
||||
|
||||
interface CheckBoxProps {
|
||||
name: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export default CheckBox
|
||||
11
components/Form/CsrfToken.tsx
Normal file
11
components/Form/CsrfToken.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import { Field } from 'formik'
|
||||
|
||||
const CsrfToken: React.FC<CsrfTokenProps> = ({ token }) => {
|
||||
return <Field name='_csrf' hidden value={token} readOnly />
|
||||
}
|
||||
|
||||
interface CsrfTokenProps {
|
||||
token: string
|
||||
}
|
||||
|
||||
export default CsrfToken
|
||||
18
components/Form/Input.tsx
Normal file
18
components/Form/Input.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import { Field } from 'formik'
|
||||
|
||||
const Input: React.FC<InputProps> = ({ name, placeholder, ...props }) => {
|
||||
return <Field
|
||||
{...props}
|
||||
name={name}
|
||||
className='border-grey-light relative px-3 w-full h-10 text-black dark:text-white dark:bg-very-black border dark:border-transparent rounded outline-none'
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
}
|
||||
|
||||
interface InputProps {
|
||||
name: string
|
||||
placeholder?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export default Input
|
||||
44
components/Form/Label.tsx
Normal file
44
components/Form/Label.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
const Label: React.FC<LabelProps> = ({
|
||||
For,
|
||||
children,
|
||||
label,
|
||||
labelDesc,
|
||||
error = null,
|
||||
grid = true,
|
||||
short = false,
|
||||
required = false,
|
||||
}) => {
|
||||
return <label
|
||||
className={grid ? 'grid grid-cols-1 xl:grid-cols-4 gap-2 my-4' : 'inline-flex items-center'}
|
||||
htmlFor={For}
|
||||
>
|
||||
{label && (
|
||||
<div className='col-span-1 text-sm'>
|
||||
<h3 className='text-koreanbots-blue text-lg font-bold'>
|
||||
{label}
|
||||
{required && (
|
||||
<span className='align-text-top text-red-500 text-base font-semibold'> *</span>
|
||||
)}
|
||||
</h3>
|
||||
{labelDesc}
|
||||
</div>
|
||||
)}
|
||||
<div className={short ? 'col-span-1' : 'col-span-3'}>
|
||||
{children}
|
||||
<div className='mt-1 text-red-500 text-xs font-light'>{error}</div>
|
||||
</div>
|
||||
</label>
|
||||
}
|
||||
|
||||
interface LabelProps {
|
||||
For: string
|
||||
children: JSX.Element | JSX.Element[]
|
||||
label?: string
|
||||
labelDesc?: string | JSX.Element
|
||||
error?: string | null
|
||||
grid?: boolean
|
||||
short?: boolean
|
||||
required?: boolean
|
||||
}
|
||||
|
||||
export default Label
|
||||
43
components/Form/Select.tsx
Normal file
43
components/Form/Select.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
import ReactSelect from 'react-select'
|
||||
|
||||
const Select: React.FC<SelectProps> = ({ placeholder, options, handleChange, handleTouch, value }) => {
|
||||
return <ReactSelect
|
||||
styles={{
|
||||
control: provided => {
|
||||
return { ...provided, border: 'none' }
|
||||
},
|
||||
option: provided => {
|
||||
return {
|
||||
...provided,
|
||||
cursor: 'pointer',
|
||||
':hover': {
|
||||
opacity: '0.7',
|
||||
},
|
||||
}
|
||||
},
|
||||
}}
|
||||
className='border-grey-light border dark:border-transparent rounded'
|
||||
classNamePrefix='outline-none text-black dark:bg-very-black dark:text-white '
|
||||
placeholder={placeholder || '선택해주세요.'}
|
||||
options={options}
|
||||
onChange={handleChange}
|
||||
onBlur={handleTouch}
|
||||
noOptionsMessage={() => '검색 결과가 없습니다.'}
|
||||
defaultValue={value}
|
||||
/>
|
||||
}
|
||||
|
||||
interface SelectProps {
|
||||
placeholder?: string
|
||||
handleChange: (value: Option) => void
|
||||
handleTouch: () => void
|
||||
options: Option[]
|
||||
value?: Option
|
||||
}
|
||||
|
||||
interface Option {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export default Select
|
||||
74
components/Form/Selects.tsx
Normal file
74
components/Form/Selects.tsx
Normal file
@ -0,0 +1,74 @@
|
||||
import { ComponentType } from 'react'
|
||||
import ReactSelect, { components, GroupTypeBase, MultiValueProps, OptionTypeBase } from 'react-select'
|
||||
import {
|
||||
SortableContainer,
|
||||
SortableElement,
|
||||
SortableHandle,
|
||||
} from 'react-sortable-hoc'
|
||||
|
||||
function arrayMove(array, from, to) {
|
||||
array = array.slice()
|
||||
array.splice(to < 0 ? array.length + to : to, 0, array.splice(from, 1)[0])
|
||||
return array
|
||||
}
|
||||
|
||||
const SortableMultiValue = SortableElement(props => {
|
||||
// this prevents the menu from being opened/closed when the user clicks
|
||||
// on a value to begin dragging it. ideally, detecting a click (instead of
|
||||
// a drag) would still focus the control and toggle the menu, but that
|
||||
// requires some magic with refs that are out of scope for this example
|
||||
const onMouseDown = e => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}
|
||||
const innerProps = { ...props.innerProps, onMouseDown }
|
||||
return <components.MultiValue {...props} innerProps={innerProps} />
|
||||
})
|
||||
|
||||
const SortableMultiValueLabel = SortableHandle(props => (
|
||||
<components.MultiValueLabel {...props} />
|
||||
))
|
||||
|
||||
const SortableSelect = SortableContainer(ReactSelect)
|
||||
|
||||
const Select: React.FC<SelectProps> = ({ placeholder, options, values, setValues, handleChange, handleTouch }) => {
|
||||
const onSortEnd = ({ oldIndex, newIndex }) => {
|
||||
const newValue = arrayMove(values, oldIndex, newIndex)
|
||||
setValues(newValue)
|
||||
}
|
||||
return <SortableSelect useDragHandle axis='xy' distance={4} getHelperDimensions={({ node }) => node.getBoundingClientRect()} onSortEnd={onSortEnd}
|
||||
// select props
|
||||
styles={{
|
||||
control: (provided) => {
|
||||
return { ...provided, border: 'none' }
|
||||
},
|
||||
option: (provided) => {
|
||||
return { ...provided, cursor: 'pointer', ':hover': {
|
||||
opacity: '0.7'
|
||||
} }
|
||||
}
|
||||
}} isMulti className='border border-grey-light dark:border-transparent rounded' classNamePrefix='outline-none text-black dark:bg-very-black dark:text-white cursor-pointer ' placeholder={placeholder || '선택해주세요.'} options={options} onChange={handleChange} onBlur={handleTouch} noOptionsMessage={() => '검색 결과가 없습니다.'}
|
||||
value={values.map(el => ({ label: el, value: el}))}
|
||||
components={{
|
||||
MultiValue: SortableMultiValue as ComponentType<MultiValueProps<OptionTypeBase, GroupTypeBase<{ label: string, value: string}>>>,
|
||||
MultiValueLabel: SortableMultiValueLabel,
|
||||
}}
|
||||
closeMenuOnSelect={false}
|
||||
/>
|
||||
}
|
||||
|
||||
interface SelectProps {
|
||||
placeholder?: string
|
||||
options: Option[]
|
||||
values: string[]
|
||||
setValues: (value: string[]) => void
|
||||
handleChange: (value: Option[]) => void
|
||||
handleTouch: () => void
|
||||
}
|
||||
|
||||
interface Option {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export default Select
|
||||
69
components/Form/TextArea.tsx
Normal file
69
components/Form/TextArea.tsx
Normal file
@ -0,0 +1,69 @@
|
||||
/* eslint-disable jsx-a11y/no-autofocus */
|
||||
import { useRef, useState } from 'react'
|
||||
import { Field } from 'formik'
|
||||
import { Picker } from 'emoji-mart'
|
||||
|
||||
import { KoreanbotsEmoji } from '@utils/Constants'
|
||||
import useOutsideClick from '@utils/useOutsideClick'
|
||||
|
||||
import 'emoji-mart/css/emoji-mart.css'
|
||||
|
||||
|
||||
|
||||
const TextArea: React.FC<TextAreaProps> = ({ name, placeholder, theme='auto', max, setValue, value }) => {
|
||||
const ref = useRef()
|
||||
const [ emojiPickerHidden, setEmojiPickerHidden ] = useState(true)
|
||||
useOutsideClick(ref, () => {
|
||||
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'>
|
||||
<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 ref={ref}>
|
||||
<div className='absolute bottom-12 left-10 z-30'>
|
||||
{
|
||||
!emojiPickerHidden && <Picker title='선택해주세요' emoji='sunglasses' set='twitter' enableFrequentEmojiSort theme={theme} showSkinTones={false} onSelect={(e) => {
|
||||
setEmojiPickerHidden(true)
|
||||
setValue(value + ' ' + ((e as { native: string }).native || e.colons))
|
||||
}} i18n={{
|
||||
search: '검색',
|
||||
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>
|
||||
}
|
||||
|
||||
interface TextAreaProps {
|
||||
name: string
|
||||
placeholder?: string
|
||||
theme?: 'auto' | 'dark' | 'light'
|
||||
max?: number
|
||||
value: string
|
||||
setValue(value: string): void
|
||||
}
|
||||
|
||||
export default TextArea
|
||||
|
||||
44
components/Hero.tsx
Normal file
44
components/Hero.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
import dynamic from 'next/dynamic'
|
||||
import { NextSeo } from 'next-seo'
|
||||
|
||||
import { categories, categoryIcon } from '@utils/Constants'
|
||||
|
||||
const Container = dynamic(()=> import('@components/Container'))
|
||||
const Tag = dynamic(()=> import('@components/Tag'))
|
||||
const Search = dynamic(()=> import('@components/Search'))
|
||||
|
||||
const Hero:React.FC<HeroProps> = ({ header, description }) => {
|
||||
return <>
|
||||
<NextSeo title={header} description={description} />
|
||||
<div className='dark:bg-discord-black bg-discord-blurple text-gray-100 md:p-0 mb-8'>
|
||||
<Container className='pt-24 pb-16 md:pb-20' ignoreColor>
|
||||
<h1 className='hidden md:block text-left text-3xl font-bold'>
|
||||
{ header && `${header} - `}한국 디스코드봇 리스트
|
||||
</h1>
|
||||
<h1 className='md:hidden text-center text-3xl font-semibold'>
|
||||
{ header && <span className='text-4xl'>{header}<br/></span>}한국 디스코드봇 리스트
|
||||
</h1>
|
||||
<p className='text-center sm:text-left text-xl font-base mt-2'>{description || '다양한 국내 디스코드봇을 한곳에서 확인하세요!'}</p>
|
||||
<Search />
|
||||
<div className='flex flex-wrap mt-5'>
|
||||
<Tag key='list' text={<>
|
||||
<i className='fas fa-heart text-red-600'/> 하트 랭킹
|
||||
</>} dark bigger href='/list/votes' />
|
||||
{ categories.slice(0, 4).map(t=> <Tag key={t} text={<>
|
||||
<i className={categoryIcon[t]} /> {t}
|
||||
</>} dark bigger href={`/categories/${t}`} />) }
|
||||
<Tag key='tag' text={<>
|
||||
<i className='fas fa-tag'/> 카테고리 더보기
|
||||
</>} dark bigger href='/categories' />
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
|
||||
interface HeroProps {
|
||||
header?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export default Hero
|
||||
20
components/Loader.tsx
Normal file
20
components/Loader.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
const Loader: React.FC<LoaderProps> = ({ text, visible = true }) => {
|
||||
return (
|
||||
<div
|
||||
className={`${
|
||||
visible ? '' : 'hidden '
|
||||
} w-full h-full fixed block top-0 left-0 bg-gray-500 bg-opacity-75 z-50 dark:text-black`}
|
||||
>
|
||||
<h1 className='relative top-1/2 block mx-auto my-0 text-center text-2xl font-semibold opacity-100'>
|
||||
{text}
|
||||
</h1>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface LoaderProps {
|
||||
text: string | React.ReactNode
|
||||
visible?: boolean
|
||||
}
|
||||
|
||||
export default Loader
|
||||
17
components/Login.tsx
Normal file
17
components/Login.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useRouter } from 'next/router'
|
||||
|
||||
import { redirectTo } from '@utils/Tools'
|
||||
|
||||
const Login: React.FC = ({ children }) => {
|
||||
const router = useRouter()
|
||||
useEffect(() => {
|
||||
localStorage.redirectTo = window.location.href
|
||||
redirectTo(router, 'login')
|
||||
})
|
||||
return <>
|
||||
{children}
|
||||
</>
|
||||
}
|
||||
|
||||
export default Login
|
||||
38
components/LongButton.tsx
Normal file
38
components/LongButton.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
/* eslint-disable jsx-a11y/no-static-element-interactions */
|
||||
import Link from 'next/link'
|
||||
|
||||
const LongButton: React.FC<LongButtonProps> = ({ children, newTab=false, href, onClick, center=false }) => {
|
||||
if(href) {
|
||||
if(newTab) return <a href={href} rel='noopener noreferrer'
|
||||
target='_blank'>
|
||||
<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`}>
|
||||
{children}
|
||||
</div>
|
||||
</a>
|
||||
else return <Link href={href}>
|
||||
<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>
|
||||
</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>
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
export default LongButton
|
||||
|
||||
interface LongButtonProps {
|
||||
newTab?: boolean
|
||||
onClick?: (event: React.KeyboardEvent<HTMLDivElement>|React.MouseEvent<HTMLDivElement>) => void
|
||||
children: string | JSX.Element | JSX.Element[]
|
||||
href?: string
|
||||
center?: boolean
|
||||
}
|
||||
127
components/Markdown.tsx
Normal file
127
components/Markdown.tsx
Normal file
@ -0,0 +1,127 @@
|
||||
import { FunctionComponent } from 'react'
|
||||
import MarkdownView from 'react-showdown'
|
||||
import sanitizeHtml from 'sanitize-html'
|
||||
import Emoji from 'node-emoji'
|
||||
|
||||
import { anchorHeader, customEmoji, twemoji } from '@utils/Tools'
|
||||
|
||||
const Markdown: React.FC<MarkdownProps> = ({ text, options={}, allowedTag=[], components={} }) => {
|
||||
return (
|
||||
<div className='markdown-body w-full'>
|
||||
<MarkdownView
|
||||
markdown={Emoji.emojify(text)}
|
||||
extensions={[twemoji, customEmoji, anchorHeader]}
|
||||
options={{
|
||||
openLinksInNewWindow: true,
|
||||
underline: true,
|
||||
omitExtraWLInCodeBlocks: true,
|
||||
// literalMidWordUnderscores: true,
|
||||
simplifiedAutoLink: true,
|
||||
tables: true,
|
||||
strikethrough: true,
|
||||
smoothLivePreview: true,
|
||||
tasklists: true,
|
||||
ghCompatibleHeaderId: true,
|
||||
encodeEmails: true,
|
||||
...options
|
||||
}}
|
||||
components={components}
|
||||
sanitizeHtml={html =>
|
||||
sanitizeHtml(html, {
|
||||
allowedTags: [
|
||||
'addr',
|
||||
'address',
|
||||
'article',
|
||||
'aside',
|
||||
'h1',
|
||||
'h2',
|
||||
'h3',
|
||||
'h4',
|
||||
'h5',
|
||||
'h6',
|
||||
'section',
|
||||
'blockquote',
|
||||
'dd',
|
||||
'div',
|
||||
'dl',
|
||||
'dt',
|
||||
'hr',
|
||||
'li',
|
||||
'ol',
|
||||
'p',
|
||||
'pre',
|
||||
'ul',
|
||||
'a',
|
||||
'abbr',
|
||||
'b',
|
||||
'bdi',
|
||||
'bdo',
|
||||
'br',
|
||||
'cite',
|
||||
'code',
|
||||
'data',
|
||||
'dfn',
|
||||
'em',
|
||||
'i',
|
||||
'kbd',
|
||||
'mark',
|
||||
'q',
|
||||
'rb',
|
||||
'rp',
|
||||
'rt',
|
||||
'rtc',
|
||||
'ruby',
|
||||
's',
|
||||
'samp',
|
||||
'small',
|
||||
'span',
|
||||
'strong',
|
||||
'sub',
|
||||
'sup',
|
||||
'time',
|
||||
'u',
|
||||
'var',
|
||||
'wbr',
|
||||
'caption',
|
||||
'col',
|
||||
'colgroup',
|
||||
'table',
|
||||
'tbody',
|
||||
'td',
|
||||
'tfoot',
|
||||
'th',
|
||||
'thead',
|
||||
'tr',
|
||||
'del',
|
||||
'ins',
|
||||
'img',
|
||||
'svg',
|
||||
'path',
|
||||
'input',
|
||||
...allowedTag
|
||||
],
|
||||
allowedAttributes: false,
|
||||
allowedClasses: {
|
||||
'*': ['align-middle'],
|
||||
a: ['anchor', 'mr-1'],
|
||||
svg: ['octicon-link'],
|
||||
img: ['emoji', 'special']
|
||||
},
|
||||
allowedStyles: {}
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface MarkdownProps {
|
||||
text: string
|
||||
options?: {
|
||||
[key: string]: boolean
|
||||
}
|
||||
allowedTag?: string[]
|
||||
components?: Record<string, FunctionComponent>
|
||||
}
|
||||
|
||||
export default Markdown
|
||||
19
components/Message.tsx
Normal file
19
components/Message.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import { MessageColor } from '@utils/Constants'
|
||||
import Markdown from './Markdown'
|
||||
|
||||
const Message: React.FC<MessageProps> = ({ type, children }) => {
|
||||
return (
|
||||
<div
|
||||
className={`${MessageColor[type]} px-6 py-4 rounded-md text-base mx-auto w-full text-left`}
|
||||
>
|
||||
{ typeof children === 'string' ? <Markdown text={children} /> : children }
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface MessageProps {
|
||||
type?: 'success' | 'error' | 'warning' | 'info'
|
||||
children: JSX.Element | JSX.Element[] | string
|
||||
}
|
||||
|
||||
export default Message
|
||||
43
components/Modal.tsx
Normal file
43
components/Modal.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { Modal as ReactModal } from 'react-responsive-modal'
|
||||
import 'react-responsive-modal/styles.css'
|
||||
|
||||
const Modal: React.FC<ModalProps> = ({ children, isOpen, onClose, closeIcon=false, dark, header, full=false }) => {
|
||||
return (
|
||||
<ReactModal
|
||||
open={isOpen}
|
||||
onClose={onClose}
|
||||
center
|
||||
animationDuration={100}
|
||||
showCloseIcon={closeIcon}
|
||||
styles={{
|
||||
closeButton: {
|
||||
color: dark ? 'white' : 'black'
|
||||
},
|
||||
modal: {
|
||||
borderRadius: '10px',
|
||||
background: dark ? '#2C2F33' : '#fbfbfb',
|
||||
color: dark ? 'white' : 'black',
|
||||
width: full ? '90%' : 'inherit'
|
||||
},
|
||||
}}
|
||||
>
|
||||
<h2 className='text-lg font-bold uppercase'>{header}</h2>
|
||||
<div className='relative pt-4'>
|
||||
<div className={dark ? 'dark' : 'light'}>{children}</div>
|
||||
</div>
|
||||
</ReactModal>
|
||||
)
|
||||
}
|
||||
|
||||
interface ModalProps {
|
||||
dark: boolean
|
||||
isOpen: boolean
|
||||
header?: string
|
||||
full?: boolean
|
||||
children: ReactNode
|
||||
closeIcon?: boolean
|
||||
onClose(): void
|
||||
}
|
||||
|
||||
export default Modal
|
||||
33
components/NSFW.tsx
Normal file
33
components/NSFW.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
const Button = dynamic(() => import('@components/Button'))
|
||||
const Container = dynamic(() => import('@components/Container'))
|
||||
|
||||
const NSFW: React.FC<NSFWProps> = ({ onClick, onDisableClick }) => {
|
||||
return <Container>
|
||||
<div className='flex items-center h-screen select-none'>
|
||||
<div className='px-10'>
|
||||
<h1 className='text-2xl font-bold flex'>
|
||||
<img draggable='false' alt='⚠' src='https://twemoji.maxcdn.com/v/13.0.2/svg/26a0.svg' className='emoji mr-2 w-8' />
|
||||
해당 컨텐츠는 만19세 이상의 성인만 열람할 수 있습니다.</h1>
|
||||
<p className='text-lg mb-3'>계속하시겠습니까?</p>
|
||||
<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>
|
||||
</Container>
|
||||
}
|
||||
|
||||
interface NSFWProps {
|
||||
onClick(): void
|
||||
onDisableClick(): void
|
||||
}
|
||||
|
||||
export default NSFW
|
||||
219
components/Navbar.tsx
Normal file
219
components/Navbar.tsx
Normal file
@ -0,0 +1,219 @@
|
||||
/* eslint-disable jsx-a11y/click-events-have-key-events */
|
||||
/* eslint-disable jsx-a11y/no-noninteractive-tabindex */
|
||||
/* eslint-disable jsx-a11y/no-static-element-interactions */
|
||||
import { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { useRouter } from 'next/router'
|
||||
|
||||
import { redirectTo } from '@utils/Tools'
|
||||
import Fetch from '@utils/Fetch'
|
||||
import { User, UserCache } from '@types'
|
||||
|
||||
const DiscordAvatar = dynamic(() => import('@components/DiscordAvatar'))
|
||||
|
||||
const Navbar: React.FC<NavbarProps> = ({ token }) => {
|
||||
const [userCache, setUserCache] = useState<UserCache>()
|
||||
const [navbarOpen, setNavbarOpen] = useState<boolean>(false)
|
||||
const [dropdownOpen, setDropdownOpen] = useState<boolean>(false)
|
||||
const router = useRouter()
|
||||
const logged = userCache?.id && userCache.version === 2
|
||||
const dev = router.pathname.startsWith('/developers')
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
if(localStorage.userCache) {
|
||||
setUserCache(token ? JSON.parse(localStorage.userCache) : null)
|
||||
}
|
||||
Fetch<User>('/users/@me').then(data => {
|
||||
if(data.code !== 200) return
|
||||
setUserCache(JSON.parse(localStorage.userCache = JSON.stringify({
|
||||
id: data.data.id,
|
||||
username: data.data.username,
|
||||
tag: data.data.tag,
|
||||
version: 2
|
||||
})))
|
||||
})
|
||||
} catch {
|
||||
setUserCache(null)
|
||||
}
|
||||
}, [ token ])
|
||||
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 bg-transparent lg:absolute'>
|
||||
<div className='container flex flex-wrap items-center justify-between mx-auto px-4'>
|
||||
<div className='relative flex justify-between w-full lg:justify-start lg:w-auto'>
|
||||
<Link href={dev ? '/developers' : '/'}>
|
||||
<a className={`${dev ? '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`}
|
||||
>
|
||||
{ dev ? <><i className='fas fa-tools mr-1'/> DEVELOPERS</> : 'KOREANBOTS'}
|
||||
</a>
|
||||
</Link>
|
||||
<button
|
||||
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'
|
||||
onClick={() => setNavbarOpen(!navbarOpen)}
|
||||
>
|
||||
<i className={`fas ${!navbarOpen ? 'fa-bars' : 'fa-times'}`}></i>
|
||||
</button>
|
||||
<ul className='hidden lg:flex flex-col list-none lg:flex-row lg:ml-auto'>
|
||||
<li className='flex items-center'>
|
||||
<Link href={dev ? '/' : '/developers'}>
|
||||
<a className='lg:hover:text-gray-300 flex items-center px-3 py-4 w-full hover:text-gray-500 text-gray-700 text-sm font-semibold sm:w-auto lg:py-2 lg:text-gray-100'>
|
||||
{dev ? '홈' : '개발자'}
|
||||
</a>
|
||||
</Link>
|
||||
</li>
|
||||
<li className='flex items-center'>
|
||||
<Link href='/discord'>
|
||||
<a target='_blank' rel='noreferrer' className='lg:hover:text-gray-300 flex items-center px-3 py-4 w-full hover:text-gray-500 text-gray-700 text-sm font-semibold sm:w-auto lg:py-2 lg:text-gray-100'
|
||||
>
|
||||
디스코드
|
||||
</a>
|
||||
</Link>
|
||||
</li>
|
||||
<li className='flex items-center'>
|
||||
<Link href='/about'>
|
||||
<a className='lg:hover:text-gray-300 flex items-center px-3 py-4 w-full hover:text-gray-500 text-gray-700 text-sm font-semibold sm:w-auto lg:py-2 lg:text-gray-100'>
|
||||
소개
|
||||
</a>
|
||||
</Link>
|
||||
</li>
|
||||
<li className='flex items-center'>
|
||||
<Link href='/addbot'>
|
||||
<a className='lg:hover:text-gray-300 flex items-center px-3 py-4 w-full hover:text-gray-500 text-gray-700 text-sm font-semibold sm:w-auto lg:py-2 lg:text-gray-100'>
|
||||
봇 추가하기
|
||||
</a>
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className='hidden flex-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'>
|
||||
<li className='flex items-center outline-none' onFocus={() => setDropdownOpen(true)} onMouseOver={() => setDropdownOpen(true)} onMouseOut={() => setDropdownOpen(false)} onBlur={() => setDropdownOpen(false)}>
|
||||
{
|
||||
logged ?
|
||||
<>
|
||||
<a
|
||||
className='lg:hover:text-gray-300 flex items-center px-3 py-4 w-full hover:text-gray-500 text-gray-700 text-sm font-semibold sm:w-auto lg:py-2 lg:text-gray-100 cursor-pointer'>
|
||||
<DiscordAvatar userID={userCache.id} className='w-8 h-8 rounded-full mr-1.5' size={128}/>
|
||||
{userCache.username} <i className='ml-2 fas fa-sort-down' />
|
||||
</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'}`}>
|
||||
<ul className='relative'>
|
||||
<li>
|
||||
<Link href={`/users/${userCache.id}`}>
|
||||
<a className='px-4 py-2 block hover:bg-gray-100 dark:hover:bg-discord-dark-hover rounded-t'><i className='fas fa-user' /> 프로필</a>
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href='/panel'>
|
||||
<a className='px-4 py-2 block hover:bg-gray-100 dark:hover:bg-discord-dark-hover'><i className='fas fa-cogs' /> 관리패널</a>
|
||||
</Link>
|
||||
</li>
|
||||
{/* <li><hr className='border-t mx-2'/></li> */}
|
||||
<li>
|
||||
<a onKeyPress={() => {
|
||||
localStorage.removeItem('userCache')
|
||||
redirectTo(router, 'logout')
|
||||
}
|
||||
} onClick={() => {
|
||||
localStorage.removeItem('userCache')
|
||||
redirectTo(router, 'logout')
|
||||
}} 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>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</> :
|
||||
<a tabIndex={0} onClick={()=> {
|
||||
localStorage.redirectTo = window.location.href
|
||||
setNavbarOpen(false)
|
||||
redirectTo(router, 'login')
|
||||
}} 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'>
|
||||
로그인
|
||||
</a>
|
||||
}
|
||||
</li>
|
||||
</ul>
|
||||
</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'}>
|
||||
<a 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>
|
||||
</a>
|
||||
</Link>
|
||||
<Link href='/discord'>
|
||||
<a target='_blank' rel='noreferrer' onClick={()=> setNavbarOpen(false)} className='flex items-center px-8 py-2 text-gray-100 hover:text-gray-300'>
|
||||
<i className='fab fa-discord' />
|
||||
<span className='px-2 font-medium'>디스코드 서버</span>
|
||||
</a>
|
||||
</Link>
|
||||
<Link href='/about'>
|
||||
<a 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>
|
||||
</a>
|
||||
</Link>
|
||||
<Link href='/addbot'>
|
||||
<a onClick={()=> setNavbarOpen(false)} className='flex items-center px-8 py-2 text-gray-100 hover:text-gray-300'>
|
||||
<i className='fas fa-plus' />
|
||||
<span className='px-2 font-medium'>봇 추가하기</span>
|
||||
</a>
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
<div className='my-10'>
|
||||
{
|
||||
logged ? <>
|
||||
<Link href={`/users/${userCache.id}`}>
|
||||
<a 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>
|
||||
</a>
|
||||
</Link>
|
||||
<Link href='/panel'>
|
||||
<a 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>
|
||||
</a>
|
||||
</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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
interface NavbarProps {
|
||||
token: string
|
||||
}
|
||||
|
||||
export default Navbar
|
||||
21
components/Notice.tsx
Normal file
21
components/Notice.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
const Notice: React.FC<NoticeProps> = ({ header, desc }) => {
|
||||
return (
|
||||
<div className='mx-auto my-auto px-10 py-48 h-screen text-center'>
|
||||
<h1 className='text-4xl font-bold'>KOREANBOTS</h1>
|
||||
<br />
|
||||
<div>
|
||||
<h1 className='mb-10 text-3xl font-bold'>{header}</h1>
|
||||
|
||||
<h2 className='text-lg font-semibold'>{desc}</h2>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Notice
|
||||
|
||||
interface NoticeProps {
|
||||
header: string
|
||||
desc: string
|
||||
}
|
||||
26
components/Owner.tsx
Normal file
26
components/Owner.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import Link from 'next/link'
|
||||
import DiscordAvatar from '@components/DiscordAvatar'
|
||||
|
||||
const Owner: React.FC<OwnerProps> = ({ id, username, tag }) => {
|
||||
return (
|
||||
<Link href={`/users/${id}`}>
|
||||
<a className='dark:hover:bg-discord-dark-hover flex mb-1 px-4 py-4 text-black dark:text-gray-400 text-base dark:bg-discord-black bg-little-white hover:bg-little-white-hover rounded cursor-pointer'>
|
||||
<div className='relative flex-shrink-0 mr-3 mt-1 w-8 h-8 rounded-full shadow-inner overflow-hidden'>
|
||||
<DiscordAvatar userID={id} className='z-negative absolute inset-0 w-full h-full' />
|
||||
</div>
|
||||
<div className='flex-1 w-0 leading-snug'>
|
||||
<h4 className='whitespace-nowrap'>{username}</h4>
|
||||
<span className='text-gray-600 text-sm'>#{tag}</span>
|
||||
</div>
|
||||
</a>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export default Owner
|
||||
|
||||
interface OwnerProps {
|
||||
id: string
|
||||
tag: string
|
||||
username: string
|
||||
}
|
||||
83
components/Paginator.tsx
Normal file
83
components/Paginator.tsx
Normal file
@ -0,0 +1,83 @@
|
||||
import Link from 'next/link'
|
||||
const Paginator: React.FC<PaginatorProps> = ({ currentPage, totalPage, pathname, searchParams }) => {
|
||||
let pages = []
|
||||
if (currentPage < 4)
|
||||
pages = [
|
||||
1,
|
||||
totalPage < 2 ? null : 2,
|
||||
totalPage < 3 ? null : 3,
|
||||
totalPage < 4 ? null : 4,
|
||||
totalPage < 5 ? null : 5,
|
||||
]
|
||||
else if (currentPage > totalPage - 3)
|
||||
pages = [
|
||||
totalPage - 4 < 1 ? null : totalPage - 4,
|
||||
totalPage - 3 < 1 ? null : totalPage - 3,
|
||||
totalPage - 2 < 1 ? null : totalPage - 2,
|
||||
totalPage - 1 < 1 ? null : totalPage - 1,
|
||||
totalPage,
|
||||
]
|
||||
else
|
||||
pages = [
|
||||
currentPage - 2 < 1 ? null : currentPage - 2,
|
||||
currentPage - 1 < 1 ? null : currentPage - 1,
|
||||
currentPage,
|
||||
currentPage + 1 > totalPage ? null : currentPage + 1,
|
||||
currentPage + 2 > totalPage ? null : currentPage + 2,
|
||||
]
|
||||
pages = pages.filter(el => el)
|
||||
return (
|
||||
<div className='flex flex-col items-center justify-center py-4 text-center'>
|
||||
<div className='flex'>
|
||||
<Link href={{ pathname, hash: 'list', query: { ...searchParams, page: currentPage - 1 } }}>
|
||||
<a
|
||||
className={`${
|
||||
currentPage === 1 ? 'invisible' : ''
|
||||
} h-12 w-12 mr-1 flex justify-center items-center rounded-full transition duration-150 ease-in bg-gray-200 dark:bg-discord-black hover:bg-gray-300 dark:hover:bg-discord-dark-hover cursor-pointer text-center`}
|
||||
>
|
||||
<i className='fas fa-chevron-left'></i>
|
||||
</a>
|
||||
</Link>
|
||||
{pages.map((el, i) => (
|
||||
<Link key={i} href={{ pathname, hash: 'list', query: { ...searchParams, page: el } }}>
|
||||
<a
|
||||
className={`w-12 flex justify-center items-center cursor-pointer leading-5 transition duration-150 ease-in ${
|
||||
i === 0 && i === pages.length - 1
|
||||
? 'rounded-full'
|
||||
: i === 0
|
||||
? 'rounded-l-full'
|
||||
: i === pages.length - 1
|
||||
? 'rounded-r-full'
|
||||
: ''
|
||||
} ${
|
||||
currentPage === el
|
||||
? 'bg-gray-300 dark:bg-discord-dark-hover'
|
||||
: 'bg-gray-200 dark:bg-discord-black hover:bg-gray-300 dark:hover:bg-discord-dark-hover'
|
||||
}`}
|
||||
>
|
||||
{el}
|
||||
</a>
|
||||
</Link>
|
||||
))}
|
||||
<Link href={{ pathname, hash: 'list', query: { ...searchParams, page: currentPage + 1 } }}>
|
||||
<a
|
||||
className={`${
|
||||
currentPage === totalPage ? 'invisible' : ''
|
||||
} h-12 w-12 ml-1 flex justify-center items-center rounded-full transition duration-150 ease-in bg-gray-200 dark:bg-discord-black hover:bg-gray-300 dark:hover:bg-discord-dark-hover cursor-pointer text-center`}
|
||||
>
|
||||
<i className='fas fa-chevron-right'></i>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface PaginatorProps {
|
||||
pathname: string
|
||||
currentPage: number
|
||||
totalPage: number
|
||||
searchParams?: Record<string, string|string[]>
|
||||
}
|
||||
|
||||
export default Paginator
|
||||
13
components/PlatformDisplay.tsx
Normal file
13
components/PlatformDisplay.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
const PlatformDisplay: React.FC<PlatformDisplayProps> = ({ osx, children }:PlatformDisplayProps) => {
|
||||
const isOSX = /(Mac|iPhone|iPod|iPad)/i.test(navigator.platform)
|
||||
return <>{isOSX ? osx ?? children : children}</>
|
||||
}
|
||||
|
||||
interface PlatformDisplayProps {
|
||||
osx?: ReactNode
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export default PlatformDisplay
|
||||
31
components/Redirect.tsx
Normal file
31
components/Redirect.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import { ReactNode, useEffect } from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { useRouter } from 'next/router'
|
||||
|
||||
import { redirectTo } from '@utils/Tools'
|
||||
|
||||
const Container = dynamic(() => import('@components/Container'))
|
||||
|
||||
const Redirect: React.FC<RedirectProps> = ({ to, text=true, children }) => {
|
||||
const router = useRouter()
|
||||
if(!to) throw new Error('No Link')
|
||||
useEffect(() => {
|
||||
redirectTo(router, to)
|
||||
})
|
||||
if(children) return <>
|
||||
{children}
|
||||
</>
|
||||
return <Container paddingTop>
|
||||
<div>
|
||||
<a href={to} className='text-blue-400'>{text && '자동으로 리다이렉트되지 않는다면 클릭하세요.'}</a>
|
||||
</div>
|
||||
</Container>
|
||||
}
|
||||
|
||||
interface RedirectProps {
|
||||
to: string
|
||||
text?: boolean
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
export default Redirect
|
||||
7
components/ResponsiveGrid.tsx
Normal file
7
components/ResponsiveGrid.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
const ResponsiveGrid: React.FC = ({ 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'>
|
||||
{children}
|
||||
</div>
|
||||
}
|
||||
|
||||
export default ResponsiveGrid
|
||||
21
components/SEO.tsx
Normal file
21
components/SEO.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import Head from 'next/head'
|
||||
|
||||
const SEO: React.FC<SEOProps> = ({ title, description, image }: SEOProps) => {
|
||||
return (
|
||||
<Head>
|
||||
<title>{title} - 한국 디스코드봇 리스트</title>
|
||||
{description && <meta name='description' content={description} />}
|
||||
<meta name='og:title' content={title} />
|
||||
{description && <meta name='og:description' content={description} />}
|
||||
{image && <meta name='og:image' content={image} />}
|
||||
</Head>
|
||||
)
|
||||
}
|
||||
|
||||
export default SEO
|
||||
|
||||
interface SEOProps {
|
||||
title: string
|
||||
description?: string
|
||||
image?: string
|
||||
}
|
||||
180
components/Search.tsx
Normal file
180
components/Search.tsx
Normal file
@ -0,0 +1,180 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useRouter } from 'next/router'
|
||||
import Link from 'next/link'
|
||||
import AbortController from 'abort-controller'
|
||||
|
||||
import { makeBotURL, redirectTo } from '@utils/Tools'
|
||||
import Fetch from '@utils/Fetch'
|
||||
import { BotList, ResponseProps } from '@types'
|
||||
|
||||
import DiscordAvatar from '@components/DiscordAvatar'
|
||||
import Day from '@utils/Day'
|
||||
import useOutsideClick from '@utils/useOutsideClick'
|
||||
|
||||
const Search: React.FC = () => {
|
||||
const router = useRouter()
|
||||
const ref = useRef()
|
||||
const [query, setQuery] = useState('')
|
||||
const [recentSearch, setRecentSearch] = useState([])
|
||||
const [data, setData] = useState<ResponseProps<BotList>>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [abortControl, setAbortControl] = useState(new AbortController())
|
||||
const [hidden, setHidden] = useState(true)
|
||||
useEffect(() => {
|
||||
setQuery('')
|
||||
setData(null)
|
||||
setLoading(false)
|
||||
try {
|
||||
setRecentSearch(JSON.parse(localStorage.recentSearch))
|
||||
} catch {
|
||||
setRecentSearch([])
|
||||
}
|
||||
}, [router])
|
||||
useOutsideClick(ref, () => setHidden(true))
|
||||
const SearchResults = async (value: string) => {
|
||||
setQuery(value)
|
||||
try {
|
||||
abortControl.abort()
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
const controller = new AbortController()
|
||||
setAbortControl(controller)
|
||||
if (value.length > 1) setLoading(true)
|
||||
const res = await Fetch<BotList>(`/search/bots?q=${encodeURIComponent(value)}`, {
|
||||
signal: controller.signal,
|
||||
}).catch((e) => {
|
||||
if(e.name !== 'AbortError') throw e
|
||||
else return
|
||||
})
|
||||
setData(res || {})
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
const onSubmit = () => {
|
||||
if(query.length < 2) return
|
||||
if(!localStorage.recentSearch) localStorage.recentSearch = '[]'
|
||||
try {
|
||||
const d = JSON.parse(localStorage.recentSearch).reverse()
|
||||
if(d.findIndex(n => n.value === query) !== -1) d.splice(d.findIndex(n => n.value === query), 1)
|
||||
d.push({
|
||||
value: query,
|
||||
date: Date.now()
|
||||
})
|
||||
d.reverse()
|
||||
setRecentSearch(d.slice(0, 10))
|
||||
localStorage.recentSearch = JSON.stringify(d.slice(0, 10))
|
||||
} catch {
|
||||
setRecentSearch([{
|
||||
value: query,
|
||||
date: Date.now()
|
||||
}])
|
||||
localStorage.recentSearch = JSON.stringify(recentSearch)
|
||||
} finally {
|
||||
redirectTo(router, `/search/?q=${encodeURIComponent(query)}`)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div onFocus={() => setHidden(false)} ref={ref}>
|
||||
<div
|
||||
className='relative z-10 flex mt-5 w-full text-black dark:text-gray-100 dark:bg-very-black bg-white rounded-lg'
|
||||
>
|
||||
<input
|
||||
maxLength={50}
|
||||
className='flex-grow pr-20 px-7 py-3 h-16 text-xl bg-transparent border-0 border-none outline-none shadow'
|
||||
placeholder='검색...'
|
||||
value={query}
|
||||
onChange={e => {
|
||||
SearchResults(e.target.value)
|
||||
}}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') {
|
||||
onSubmit()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
className='cusor-pointer absolute right-0 top-0 mr-5 mt-5 outline-none'
|
||||
onClick={onSubmit}
|
||||
>
|
||||
<i className='fas fa-search text-gray-600 hover:text-gray-700 text-2xl' />
|
||||
</button>
|
||||
</div>
|
||||
<div className={`relative ${hidden ? 'hidden' : 'block'} z-50`}>
|
||||
<div className='pin-t pin-l absolute my-2 w-full h-60 text-black dark:text-gray-100 dark:bg-very-black bg-white rounded shadow-md overflow-y-scroll md:h-80'>
|
||||
<ul>
|
||||
{data && data.code === 200 && data.data ? (
|
||||
data.data.data.length === 0 ? (
|
||||
<li className='px-3 py-3.5'>검색 결과가 없습니다.</li>
|
||||
) : (
|
||||
data.data.data.map(el => (
|
||||
<Link key={el.id} href={makeBotURL(el)}>
|
||||
<li className='h-15 flex px-3 py-2 cursor-pointer'>
|
||||
<DiscordAvatar className='mt-1 w-12 h-12' size={128} userID={el.id} />
|
||||
<div className='ml-2'>
|
||||
<h1 className='text-black dark:text-gray-100 text-lg'>{el.name}</h1>
|
||||
<p className='text-gray-400 text-sm'>{el.intro}</p>
|
||||
</div>
|
||||
</li>
|
||||
</Link>
|
||||
))
|
||||
)
|
||||
) : loading ? (
|
||||
<li className='px-3 py-3.5'>검색중입니다...</li>
|
||||
) : (
|
||||
<>
|
||||
{query && data ? (
|
||||
data.message?.includes('문법') ? (
|
||||
<li className='px-3 py-3.5'>
|
||||
검색 문법이 잘못되었습니다.
|
||||
<br />
|
||||
<a
|
||||
className='hover:text-blue-400 text-blue-500'
|
||||
href='https://docs.koreanbots.dev/bots/usage/search'
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
더 알아보기
|
||||
</a>
|
||||
</li>
|
||||
) : <li className='px-3 py-3.5'>{(data.errors && data.errors[0]) || data.message}</li>
|
||||
) : query.length === 0 ? !recentSearch || !Array.isArray(recentSearch) || recentSearch.length === 0? <li className='px-3 py-3.5'>최근 검색 기록이 없습니다.</li>
|
||||
: <>
|
||||
<li className='h-15 px-3 py-2 cursor-pointer font-semibold'>
|
||||
최근 검색어
|
||||
<button className='absolute right-0 pr-10 text-sm text-red-500 hover:opacity-90' onClick={() => {
|
||||
setRecentSearch([])
|
||||
localStorage.recentSearch = '[]'
|
||||
}}>
|
||||
전체 삭제
|
||||
</button>
|
||||
</li>
|
||||
{
|
||||
recentSearch.slice(0, 10).map((el, n) => (
|
||||
<Link key={n} href={`/search?q=${encodeURIComponent(el?.value)}`}>
|
||||
<li className='h-15 px-3 py-2 cursor-pointer'>
|
||||
<i className='fas fa-history' /> {el?.value}
|
||||
<span className='absolute right-0 pr-10 text-gray-400 text-sm'>
|
||||
{Day(el?.date).format('MM.DD.')}
|
||||
</span>
|
||||
</li>
|
||||
</Link>
|
||||
))
|
||||
}
|
||||
</> :
|
||||
query.length < 3 ? (
|
||||
'최소 2글자 이상 입력해주세요.'
|
||||
) : (
|
||||
'검색어를 입력해주세요.'
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Search
|
||||
18
components/Segment.tsx
Normal file
18
components/Segment.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
const Segment: React.FC<SegmentProps> = ({ children, className = '' }) => {
|
||||
return (
|
||||
<div
|
||||
className={`py-3 px-7 text-black dark:text-white dark:bg-discord-black bg-little-white rounded ${className}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface SegmentProps {
|
||||
className?: string
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export default Segment
|
||||
47
components/SubmittedBotCard.tsx
Normal file
47
components/SubmittedBotCard.tsx
Normal file
@ -0,0 +1,47 @@
|
||||
import { Status } from '@utils/Constants'
|
||||
|
||||
import Tag from '@components/Tag'
|
||||
|
||||
import { SubmittedBot } from '@types'
|
||||
import Link from 'next/link'
|
||||
|
||||
const SubmittedBotCard: React.FC<SubmittedBotProps> = ({ href, submit }) => {
|
||||
return (
|
||||
<Link href={href}>
|
||||
<a className='relative mx-auto px-4 py-5 w-full h-full text-black dark:text-white dark:bg-discord-black bg-little-white rounded-2xl shadow-xl transform hover:-translate-y-1 transition duration-100 ease-in'>
|
||||
<div className='h-18'>
|
||||
<div className='flex'>
|
||||
<div className='flex-grow w-full'>
|
||||
<h2 className='text-lg'>{submit.id}</h2>
|
||||
</div>
|
||||
<div className='absolute right-0 grid grid-cols-1 px-4 w-2/5 h-0'>
|
||||
<Tag
|
||||
text={
|
||||
<>
|
||||
<i
|
||||
className={`fas fa-circle text-${
|
||||
[Status.offline, Status.online, Status.dnd][submit.state]?.color
|
||||
}`}
|
||||
/>{' '}
|
||||
{['대기중', '승인됨', '거부됨'][submit.state]}
|
||||
</>
|
||||
}
|
||||
dark
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className='mt-1.5 w-full h-6 text-left text-gray-400 text-sm font-medium truncate'>
|
||||
{submit.intro.slice(0, 25)}
|
||||
{submit.intro.length > 25 && '...'}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
interface SubmittedBotProps {
|
||||
href: string
|
||||
submit: SubmittedBot
|
||||
}
|
||||
export default SubmittedBotCard
|
||||
107
components/Tag.tsx
Normal file
107
components/Tag.tsx
Normal file
@ -0,0 +1,107 @@
|
||||
import Link from 'next/link'
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
const Tag: React.FC<LabelProps> = ({
|
||||
blurple = false,
|
||||
github = false,
|
||||
href,
|
||||
text,
|
||||
className,
|
||||
circular = false,
|
||||
dark = false,
|
||||
marginBottom = 2,
|
||||
newTab = false,
|
||||
bigger = false,
|
||||
...props
|
||||
}) => {
|
||||
return href ? (
|
||||
newTab ? (
|
||||
<a
|
||||
href={href}
|
||||
rel='noopener noreferrer'
|
||||
target='_blank'
|
||||
className={`${className ?? ''} text-center text-base ${
|
||||
dark
|
||||
? blurple
|
||||
? 'bg-discord-blurple text-white'
|
||||
: 'bg-little-white-hover hover:bg-little-white dark:bg-very-black'
|
||||
: github
|
||||
? 'bg-gray-900 text-white hover:bg-gray-700'
|
||||
: 'bg-little-white dark:bg-discord-black hover:bg-little-white-hover'
|
||||
} ${!blurple && !github ? 'text-black dark:text-gray-400' : ''} ${
|
||||
circular
|
||||
? `rounded-3xl ${bigger ? 'px-3.5 py-2.5' : 'px-2.5 py-1.5'}`
|
||||
: `rounded ${bigger ? 'px-3 py-2' : 'px-2 py-1'}`
|
||||
} mr-1 mb-${marginBottom} dark:hover:bg-discord-dark-hover transition duration-100 ease-in`}
|
||||
>
|
||||
{text}
|
||||
</a>
|
||||
) : (
|
||||
<Link href={href}>
|
||||
<a
|
||||
className={`${className ?? ''} text-center text-base ${
|
||||
dark
|
||||
? blurple
|
||||
? 'bg-discord-blurple text-white'
|
||||
: 'bg-little-white-hover hover:bg-little-white dark:bg-very-black'
|
||||
: github
|
||||
? 'bg-gray-900 text-white hover:bg-gray-700'
|
||||
: 'bg-little-white dark:bg-discord-black hover:bg-little-white-hover'
|
||||
} ${
|
||||
!blurple && !github ? 'text-black dark:text-gray-400' : 'hover:bg-little-white-hover'
|
||||
} ${
|
||||
circular
|
||||
? `rounded-3xl ${bigger ? 'px-3.5 py-2.5' : 'px-2.5 py-1.5'}`
|
||||
: `rounded ${bigger ? 'px-3 py-2' : 'px-2 py-1'}`
|
||||
} mr-1 mb-${marginBottom} dark:hover:bg-discord-dark-hover transition duration-100 ease-in`}
|
||||
>
|
||||
{text}
|
||||
</a>
|
||||
</Link>
|
||||
)
|
||||
) : (
|
||||
<a
|
||||
{...props}
|
||||
className={`${className ?? ''} text-center text-base ${
|
||||
dark
|
||||
? blurple
|
||||
? 'font-bg bg-discord-blurple text-white'
|
||||
: github
|
||||
? 'bg-gray-900 text-white hover:bg-gray-700'
|
||||
: `bg-little-white-hover dark:bg-very-black ${
|
||||
props.onClick
|
||||
? 'hover:bg-little-white dark:hover:bg-discord-dark-hover transition duration-100 ease-in'
|
||||
: ''
|
||||
}`
|
||||
: `bg-little-white dark:bg-discord-black ${
|
||||
props.onClick
|
||||
? 'hover:bg-little-white-hover dark:hover:bg-discord-dark-hover transition duration-100 ease-in'
|
||||
: ''
|
||||
}`
|
||||
} ${!blurple && !github ? 'text-black dark:text-gray-400' : ''} ${
|
||||
circular
|
||||
? `rounded-3xl ${bigger ? 'px-3.5 py-2.5' : 'px-2.5 py-1.5'}`
|
||||
: `rounded ${bigger ? 'px-3 py-2' : 'px-2 py-1'}`
|
||||
} mr-1 mb-${marginBottom}`}
|
||||
>
|
||||
{text}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
interface LabelProps {
|
||||
blurple?: boolean
|
||||
github?: boolean
|
||||
href?: string
|
||||
text: ReactNode
|
||||
className?: string
|
||||
icon?: string
|
||||
circular?: boolean
|
||||
dark?: boolean
|
||||
marginBottom?: number
|
||||
newTab?: boolean
|
||||
bigger?: boolean
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export default Tag
|
||||
28
components/Toggle.tsx
Normal file
28
components/Toggle.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
const Toggle: React.FC<ToggleProps> = ({ checked, onChange }: ToggleProps) => {
|
||||
return (
|
||||
<button
|
||||
className='relative inline-block align-middle mr-2 w-10 outline-none select-none'
|
||||
onClick={onChange}
|
||||
onKeyPress={onChange}
|
||||
>
|
||||
<input
|
||||
type='checkbox'
|
||||
checked={checked}
|
||||
className='absolute checked:right-0 block w-6 h-6 bg-white border-4 border-transparent rounded-full outline-none appearance-none cursor-pointer'
|
||||
readOnly
|
||||
/>
|
||||
<span
|
||||
className={`block overflow-hidden h-6 rounded-full bg-gray-300 cursor-pointer ${
|
||||
checked ? 'bg-koreanbots-blue' : ''
|
||||
}`}
|
||||
></span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
interface ToggleProps {
|
||||
checked: boolean
|
||||
onChange(): void
|
||||
}
|
||||
|
||||
export default Toggle
|
||||
115
components/Tooltip.tsx
Normal file
115
components/Tooltip.tsx
Normal file
@ -0,0 +1,115 @@
|
||||
import Link from 'next/link'
|
||||
|
||||
const Tooltip: React.FC<TooltipProps> = ({
|
||||
href,
|
||||
size = 'small',
|
||||
children,
|
||||
direction = 'center',
|
||||
text,
|
||||
}) => {
|
||||
return href ? (
|
||||
<Link href={href}>
|
||||
<a className='inline'>
|
||||
<div className='relative inline py-3'>
|
||||
<div className='group relative inline-block text-center cursor-pointer'>
|
||||
{children}
|
||||
<div
|
||||
className={`opacity-0 ${
|
||||
size === 'small' ? 'w-44' : 'w-60'
|
||||
} bg-black text-white text-center text-xs rounded-lg py-2 px-3 absolute z-10 group-hover:opacity-100 bottom-full -left-4 pointer-events-none`}
|
||||
>
|
||||
{text}
|
||||
{direction === 'left' ? (
|
||||
<svg
|
||||
className='absolute left-5 top-full mr-3 h-2 text-black'
|
||||
x='0px'
|
||||
y='0px'
|
||||
viewBox='0 0 255 255'
|
||||
xmlSpace='preserve'
|
||||
>
|
||||
<polygon className='fill-current' points='0,0 127.5,127.5 255,0' />
|
||||
</svg>
|
||||
) : direction === 'center' ? (
|
||||
<svg
|
||||
className='absolute left-0 top-full w-full h-2 text-black'
|
||||
x='0px'
|
||||
y='0px'
|
||||
viewBox='0 0 255 255'
|
||||
xmlSpace='preserve'
|
||||
>
|
||||
<polygon className='fill-current' points='0,0 127.5,127.5 255,0' />
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
className='absolute right-5 top-full mr-3 h-2 text-black'
|
||||
x='0px'
|
||||
y='0px'
|
||||
viewBox='0 0 255 255'
|
||||
xmlSpace='preserve'
|
||||
>
|
||||
<polygon className='fill-current' points='0,0 127.5,127.5 255,0' />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</Link>
|
||||
) : (
|
||||
<a className='inline'>
|
||||
<div className='relative inline py-3'>
|
||||
<div className='group relative inline-block text-center cursor-pointer'>
|
||||
{children}
|
||||
<div
|
||||
className={`opacity-0 ${
|
||||
size === 'small' ? 'w-44' : 'w-60'
|
||||
} bg-black text-white text-center text-xs rounded-lg py-2 px-3 absolute z-10 group-hover:opacity-100 bottom-full -left-4 pointer-events-none`}
|
||||
>
|
||||
{text}
|
||||
{direction === 'left' ? (
|
||||
<svg
|
||||
className='absolute left-5 top-full mr-3 h-2 text-black'
|
||||
x='0px'
|
||||
y='0px'
|
||||
viewBox='0 0 255 255'
|
||||
xmlSpace='preserve'
|
||||
>
|
||||
<polygon className='fill-current' points='0,0 127.5,127.5 255,0' />
|
||||
</svg>
|
||||
) : direction === 'center' ? (
|
||||
<svg
|
||||
className='absolute left-0 top-full w-full h-2 text-black'
|
||||
x='0px'
|
||||
y='0px'
|
||||
viewBox='0 0 255 255'
|
||||
xmlSpace='preserve'
|
||||
>
|
||||
<polygon className='fill-current' points='0,0 127.5,127.5 255,0' />
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
className='absolute right-5 top-full mr-3 h-2 text-black'
|
||||
x='0px'
|
||||
y='0px'
|
||||
viewBox='0 0 255 255'
|
||||
xmlSpace='preserve'
|
||||
>
|
||||
<polygon className='fill-current' points='0,0 127.5,127.5 255,0' />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
interface TooltipProps {
|
||||
href?: string
|
||||
size?: 'small' | 'large'
|
||||
direction?: 'left' | 'center' | 'right'
|
||||
text: string
|
||||
children: JSX.Element | JSX.Element[]
|
||||
}
|
||||
|
||||
export default Tooltip
|
||||
17
components/Wave.tsx
Normal file
17
components/Wave.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
const Wave: React.FC<WaveProps> = ({ color, className }) => {
|
||||
return (
|
||||
<svg viewBox='0 0 1440 320' className={className}>
|
||||
<path
|
||||
fill={color}
|
||||
d='M0 192l34.3 5.3C68.6 203 137 213 206 186.7c68.3-26.7 137-90.7 205-96 69-5.7 138 48.3 206 90.6C685.7 224 754 256 823 272c68.4 16 137 16 206 0 68.1-16 137-48 205-69.3 68.9-21.7 137-31.7 172-37.4l34-5.3V0H0z'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
interface WaveProps {
|
||||
color: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default Wave
|
||||
23
docker-compose-stable.yml
Normal file
23
docker-compose-stable.yml
Normal file
@ -0,0 +1,23 @@
|
||||
version: '3'
|
||||
|
||||
services:
|
||||
mysql:
|
||||
image: wonderlandpark/mariadb-mroonga:latest
|
||||
hostname: mysql
|
||||
container_name: mysql-stable
|
||||
env_file:
|
||||
- .env
|
||||
command:
|
||||
- --character-set-server=utf8mb4
|
||||
- --collation-server=utf8mb4_unicode_ci
|
||||
volumes:
|
||||
- /home/ubuntu/mysql:/var/lib/mysql
|
||||
web:
|
||||
container_name: web-stable
|
||||
ports:
|
||||
- 5000:3000
|
||||
links:
|
||||
- mysql
|
||||
env_file:
|
||||
- .env.production.local
|
||||
image: wonderlandpark/koreanbots:stable
|
||||
27
docker-compose.yml
Normal file
27
docker-compose.yml
Normal file
@ -0,0 +1,27 @@
|
||||
version: '3'
|
||||
|
||||
services:
|
||||
mysql:
|
||||
image: wonderlandpark/mariadb-mroonga:latest
|
||||
hostname: mysql
|
||||
container_name: mysql
|
||||
env_file:
|
||||
- .env
|
||||
command:
|
||||
- --character-set-server=utf8mb4
|
||||
- --collation-server=utf8mb4_unicode_ci
|
||||
volumes:
|
||||
- /home/ubuntu/mysql-beta:/var/lib/mysql
|
||||
web:
|
||||
container_name: web
|
||||
ports:
|
||||
- 4000:3000
|
||||
links:
|
||||
- mysql
|
||||
env_file:
|
||||
- .env.beta.production.local
|
||||
image: wonderlandpark/koreanbots:nightly
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 500M
|
||||
14
eco.config.js
Normal file
14
eco.config.js
Normal file
@ -0,0 +1,14 @@
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: 'koreanbots',
|
||||
script: 'npm start',
|
||||
env: {
|
||||
NODE_ENV: 'development',
|
||||
},
|
||||
env_production: {
|
||||
NODE_ENV: 'production',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
1069
github-markdown.css
Normal file
1069
github-markdown.css
Normal file
File diff suppressed because it is too large
Load Diff
3
hooks/build
Normal file
3
hooks/build
Normal file
@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
|
||||
docker build --build-arg SENTRY_AUTH_TOKEN=$SENTRY_AUTH_TOKEN --build-arg NEXT_PUBLIC_SENTRY_DSN=$NEXT_PUBLIC_SENTRY_DSN --build-arg SENTRY_DSN=$SENTRY_DSN -f $DOCKERFILE_PATH -t $IMAGE_NAME .
|
||||
10
jest.config.js
Normal file
10
jest.config.js
Normal file
@ -0,0 +1,10 @@
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
moduleDirectories: ['node_modules', '.'],
|
||||
moduleNameMapper: {
|
||||
'@types': '<rootDir>/types',
|
||||
'^@utils/(.*)$': '<rootDir>/utils/$1',
|
||||
'^@components/(.*)$': '<rootDir>/components/$1'
|
||||
},
|
||||
}
|
||||
72
migrate.sql
72
migrate.sql
@ -5,49 +5,53 @@ use discordbots;
|
||||
ALTER TABLE `bots` CHANGE `servers` `servers` INT(11) NULL DEFAULT NULL, CHANGE `web` `web` MEDIUMTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL, CHANGE `git` `git` MEDIUMTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL, CHANGE `url` `url` MEDIUMTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL, CHANGE `category` `category` MEDIUMTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '\'[]\'', CHANGE `status` `status` MEDIUMTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL, CHANGE `avatar` `avatar` MEDIUMTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL, CHANGE `tag` `tag` MEDIUMTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, CHANGE `discord` `discord` MEDIUMTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL, CHANGE `state` `state` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '\'ok\'', CHANGE `vanity` `vanity` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL, CHANGE `bg` `bg` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL, CHANGE `banner` `banner` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL;
|
||||
|
||||
-- USING NULL
|
||||
UPDATE `bots` SET web=NULL where web='false';
|
||||
UPDATE `bots` SET git=NULL where git='false';
|
||||
UPDATE `bots` SET url=NULL where url='false';
|
||||
UPDATE `bots` SET avatar=NULL where avatar='false';
|
||||
UPDATE `bots` SET discord=NULL where discord='false';
|
||||
UPDATE `bots` SET vanity=NULL where vanity='false';
|
||||
UPDATE `bots` SET bg=NULL where bg='false';
|
||||
UPDATE `bots` SET banner=NULL where banner='false';
|
||||
ALTER TABLE `bots` ADD COLUMN partnered BOOLEAN NOT NULL DEFAULT 0;
|
||||
UPDATE `bots` SET web=NULL where web='false' or web='';
|
||||
UPDATE `bots` SET git=NULL where git='false' or git='';
|
||||
UPDATE `bots` SET `url`=NULL where `url`='false' or `url`='';
|
||||
UPDATE `bots` SET avatar=NULL where avatar='false' or avatar='';
|
||||
UPDATE `bots` SET discord=NULL where discord='false' or discord='';
|
||||
UPDATE `bots` SET vanity=NULL where vanity='false' or vanity='';
|
||||
UPDATE `bots` SET bg=NULL where bg='false' or bg='';
|
||||
UPDATE `bots` SET banner=NULL where banner='false' or banner='';
|
||||
ALTER TABLE `bots` ADD COLUMN webhook TEXT DEFAULT NULL;
|
||||
ALTER TABLE `bots` CHANGE id id VARCHAR(50) NOT NULL PRIMARY KEY;
|
||||
ALTER TABLE `bots` CHANGE `status` `status` TEXT DEFAULT NULL;
|
||||
ALTER TABLE `bots` CHANGE `name` `name` TEXT DEFAULT NULL;
|
||||
ALTER TABLE `bots` CHANGE avatar avatar TEXT DEFAULT NULL;
|
||||
ALTER TABLE `bots` CHANGE tag tag TEXT DEFAULT NULL;
|
||||
|
||||
ALTER TABLE `bots` CHANGE token token TEXT DEFAULT NULL;
|
||||
ALTER TABLE `bots` ENGINE=mroonga;
|
||||
ALTER TABLE `bots` COMMENT='engine "innodb"';
|
||||
ALTER TABLE `bots` ADD `flags` INT NOT NULL DEFAULT 0;
|
||||
ALTER TABLE `bots` ADD FULLTEXT KEY `search` (`name`, `intro`, `desc`) COMMENT 'tokenizer "TokenBigramIgnoreBlankSplitSymbolAlphaDigit"';
|
||||
|
||||
-- users TABLE
|
||||
UPDATE `users` SET perm=0;
|
||||
ALTER TABLE `users` CHANGE `perm` `perm` INT(5) NOT NULL DEFAULT '0';
|
||||
ALTER TABLE `users` ADD `flags` INT NOT NULL DEFAULT '0';
|
||||
ALTER TABLE `users` ADD `email` TEXT NULL AFTER `id`;
|
||||
ALTER TABLE `users` ADD `stared` TEXT NOT NULL DEFAULT '[]' AFTER `github
|
||||
ALTER TABLE `users` ADD `stared` TEXT NOT NULL DEFAULT '[]';
|
||||
ALTER TABLE `users` ADD `discord` TEXT NOT NULL AFTER `token`;
|
||||
UPDATE `users` SET `stared` = `votes` WHERE `id`=`id`;
|
||||
ALTER TABLE `users` CHANGE `votes` `votes` LONGTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '{}';
|
||||
ALTER TABLE `users` CHANGE `perm` `perm` TEXT NOT NULL DEFAULT 'user';
|
||||
ALTER TABLE `users` CHANGE `votes` `votes` MEDIUMTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '{}';
|
||||
ALTER TABLE `users` CHANGE `avatar` `avatar` MEDIUMTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL;
|
||||
UPDATE `users` SET votes="{}";
|
||||
|
||||
-- submitted TABLE
|
||||
ALTER TABLE `submitted` CHANGE `servers` `servers` INT(11) NULL DEFAULT NULL, CHANGE `web` `web` MEDIUMTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL, CHANGE `git` `git` MEDIUMTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL, CHANGE `url` `url` MEDIUMTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL, CHANGE `category` `category` MEDIUMTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT '\'[]\'', CHANGE `status` `status` MEDIUMTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '\'???\'', CHANGE `name` `name` MEDIUMTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL, CHANGE `avatar` `avatar` MEDIUMTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL, CHANGE `verified` `verified` TINYINT(1) NULL DEFAULT '0', CHANGE `trusted` `trusted` TINYINT(1) NULL DEFAULT '0', CHANGE `discord` `discord` MEDIUMTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL;
|
||||
ALTER TABLE `submitted` CHANGE `status` `status` MEDIUMTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL;
|
||||
|
||||
-- submits TABLE
|
||||
|
||||
CREATE TABLE `submits` (
|
||||
`id` mediumtext COLLATE utf8mb4_unicode_ci NOT NULL,
|
||||
`date` int(11) NOT NULL,
|
||||
`owners` mediumtext COLLATE utf8mb4_unicode_ci NOT NULL,
|
||||
`lib` mediumtext COLLATE utf8mb4_unicode_ci NOT NULL,
|
||||
`prefix` mediumtext COLLATE utf8mb4_unicode_ci NOT NULL,
|
||||
`intro` mediumtext COLLATE utf8mb4_unicode_ci NOT NULL,
|
||||
`desc` longtext COLLATE utf8mb4_unicode_ci NOT NULL,
|
||||
`web` mediumtext COLLATE utf8mb4_unicode_ci DEFAULT NULL,
|
||||
`git` mediumtext COLLATE utf8mb4_unicode_ci DEFAULT NULL,
|
||||
`url` mediumtext COLLATE utf8mb4_unicode_ci DEFAULT NULL,
|
||||
`category` mediumtext COLLATE utf8mb4_unicode_ci DEFAULT '\'[]\'',
|
||||
`tag` mediumtext COLLATE utf8mb4_unicode_ci DEFAULT NULL,
|
||||
`discord` mediumtext COLLATE utf8mb4_unicode_ci DEFAULT NULL,
|
||||
`state` int(1) NOT NULL DEFAULT 0,
|
||||
`reason` tinytext COLLATE utf8mb4_unicode_ci DEFAULT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
ALTER TABLE `submitted` DROP `name`;
|
||||
ALTER TABLE `submitted` DROP `tag`;
|
||||
ALTER TABLE `submitted` DROP `votes`;
|
||||
ALTER TABLE `submitted` DROP `servers`;
|
||||
ALTER TABLE `submitted` DROP `status`;
|
||||
ALTER TABLE `submitted` DROP `verified`;
|
||||
ALTER TABLE `submitted` DROP `trusted`;
|
||||
ALTER TABLE `submitted` DROP `avatar`;
|
||||
ALTER TABLE `submitted` ADD `reason` TINYTEXT NULL DEFAULT NULL;
|
||||
UPDATE `submitted` SET web=NULL where web='false' or web='';
|
||||
UPDATE `submitted` SET git=NULL where git='false' or git='';
|
||||
UPDATE `submitted` SET `url`=NULL where `url`='false' or `url`='';
|
||||
UPDATE `submitted` SET discord=NULL where discord='false' or discord='';
|
||||
|
||||
-- reports TABLE
|
||||
|
||||
|
||||
2
next-env.d.ts
vendored
Normal file
2
next-env.d.ts
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/types/global" />
|
||||
28
next.config.js
Normal file
28
next.config.js
Normal file
@ -0,0 +1,28 @@
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
const { withSentryConfig } = require('@sentry/nextjs')
|
||||
const withPWA = require('next-pwa')
|
||||
const VERSION = require('./package.json').version
|
||||
|
||||
const NextConfig = {
|
||||
webpack: (config, { isServer }) => {
|
||||
if (!isServer) {
|
||||
config.resolve.fallback.fs = false
|
||||
}
|
||||
return config
|
||||
},
|
||||
pwa: {
|
||||
disable: process.env.NODE_ENV !== 'production',
|
||||
register: false
|
||||
},
|
||||
env: {
|
||||
NEXT_PUBLIC_RELEASE_VERSION: VERSION,
|
||||
SENTRY_SKIP_AUTO_RELEASE: true
|
||||
},
|
||||
future: {
|
||||
webpack5: true,
|
||||
},
|
||||
experimental: {
|
||||
scrollRestoration: true
|
||||
}
|
||||
}
|
||||
module.exports = withSentryConfig(withPWA(NextConfig))
|
||||
101
package.json
Normal file
101
package.json
Normal file
@ -0,0 +1,101 @@
|
||||
{
|
||||
"name": "client-next",
|
||||
"version": "2.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"pre-build": "git init && git submodule init && git submodule update --remote",
|
||||
"build": "npm run pre-build && next build",
|
||||
"start": "next start | (sleep 1; wget http://localhost:3000/api/v2 -O /dev/null)",
|
||||
"lint": "eslint --ext ts,tsx .",
|
||||
"prettier": "prettier --write **/*",
|
||||
"lint:fix": "eslint --ext ts,tsx . --fix",
|
||||
"test": "jest",
|
||||
"docker": "docker-compose up -d --build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "5.15.3",
|
||||
"@hcaptcha/react-hcaptcha": "0.3.6",
|
||||
"@sentry/nextjs": "6.4.1",
|
||||
"@sentry/node": "6.4.1",
|
||||
"@sentry/react": "6.4.1",
|
||||
"@sentry/tracing": "6.4.1",
|
||||
"abort-controller": "3.0.0",
|
||||
"autoprefixer": "10.2.5",
|
||||
"badgen": "3.2.2",
|
||||
"cookie": "0.4.1",
|
||||
"csrf": "3.1.0",
|
||||
"dataloader": "2.0.0",
|
||||
"dayjs": "1.10.4",
|
||||
"difflib": "0.2.4",
|
||||
"discord.js": "12.5.3",
|
||||
"emoji-mart": "3.0.1",
|
||||
"erlpack": "0.1.3",
|
||||
"express-rate-limit": "5.2.6",
|
||||
"formik": "2.2.8",
|
||||
"generate-license-file": "1.1.0",
|
||||
"josa": "3.0.1",
|
||||
"jsonwebtoken": "8.5.1",
|
||||
"knex": "0.95.6",
|
||||
"mysql": "2.18.1",
|
||||
"next": "10.2.3",
|
||||
"next-connect": "0.10.1",
|
||||
"next-pwa": "5.2.21",
|
||||
"next-seo": "4.24.0",
|
||||
"next-session": "3.4.0",
|
||||
"node-emoji": "1.10.0",
|
||||
"nprogress": "0.2.0",
|
||||
"postcss": "8.3.0",
|
||||
"postcss-preset-env": "6.7.0",
|
||||
"rc-tooltip": "5.1.1",
|
||||
"react": "17.0.2",
|
||||
"react-dom": "17.0.2",
|
||||
"react-hotkeys": "2.0.0",
|
||||
"react-responsive-modal": "6.0.1",
|
||||
"react-select": "4.3.1",
|
||||
"react-showdown": "2.3.0",
|
||||
"react-sortable-hoc": "2.0.0",
|
||||
"react-use-clipboard": "1.0.7",
|
||||
"sanitize-html": "2.4.0",
|
||||
"tailwindcss": "2.1.2",
|
||||
"tlru": "1.0.2",
|
||||
"twemoji": "13.0.2",
|
||||
"url-regex-safe": "2.0.2",
|
||||
"yup": "0.32.9",
|
||||
"yup-locales-ko": "1.0.2",
|
||||
"zlib-sync": "0.1.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/custom-forms": "0.2.1",
|
||||
"@types/cookie": "0.4.0",
|
||||
"@types/emoji-mart": "3.0.4",
|
||||
"@types/express-rate-limit": "5.1.1",
|
||||
"@types/jest": "26.0.23",
|
||||
"@types/josa": "3.0.2",
|
||||
"@types/jsonwebtoken": "8.5.1",
|
||||
"@types/node": "14.14.43",
|
||||
"@types/node-emoji": "1.8.1",
|
||||
"@types/node-fetch": "2.5.10",
|
||||
"@types/nprogress": "0.2.0",
|
||||
"@types/rc-tooltip": "3.7.3",
|
||||
"@types/react": "17.0.6",
|
||||
"@types/react-select": "4.0.15",
|
||||
"@types/sanitize-html": "2.3.1",
|
||||
"@types/twemoji": "12.1.1",
|
||||
"@types/url-regex-safe": "1.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "4.24.0",
|
||||
"@typescript-eslint/parser": "4.24.0",
|
||||
"eslint": "7.27.0",
|
||||
"eslint-config-prettier": "8.3.0",
|
||||
"eslint-plugin-jsx-a11y": "6.4.1",
|
||||
"eslint-plugin-prettier": "3.4.0",
|
||||
"eslint-plugin-react": "7.23.2",
|
||||
"eslint-plugin-react-hooks": "4.2.0",
|
||||
"jest": "26.6.3",
|
||||
"prettier": "2.3.0",
|
||||
"prettier-plugin-tailwind": "2.2.10",
|
||||
"ts-jest": "26.5.6",
|
||||
"typescript": "4.3.2"
|
||||
},
|
||||
"license": "AGPL-3.0"
|
||||
}
|
||||
24
pages/404.tsx
Normal file
24
pages/404.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import { NextPage } from 'next'
|
||||
import { ErrorText } from '@utils/Constants'
|
||||
|
||||
const NotFound: NextPage<{ message?: string }> = ({ message }) => {
|
||||
return (
|
||||
<div
|
||||
className='flex items-center justify-center h-screen select-none text-center'
|
||||
>
|
||||
<div>
|
||||
<div className='flex flex-row justify-center text-9xl'>
|
||||
4
|
||||
<img alt='robot' src='https://twemoji.maxcdn.com/v/13.0.1/svg/1f916.svg' className='w-24 mx-6 md:mx-12 rounded-full' />
|
||||
4
|
||||
</div>
|
||||
<h2 className='text-2xl font-semibold'>
|
||||
{message || ErrorText[404]}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default NotFound
|
||||
188
pages/_app.tsx
Normal file
188
pages/_app.tsx
Normal file
@ -0,0 +1,188 @@
|
||||
import Head from 'next/head'
|
||||
import App, { AppContext, AppProps } from 'next/app'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { Router, useRouter } from 'next/router'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { DefaultSeo } from 'next-seo'
|
||||
import { GlobalHotKeys } from 'react-hotkeys'
|
||||
import NProgress from 'nprogress'
|
||||
|
||||
import Logger from '@utils/Logger'
|
||||
import { handlePWA, parseCookie, systemTheme } from '@utils/Tools'
|
||||
import { DESCRIPTION, shortcutKeyMap, THEME_COLOR, TITLE } from '@utils/Constants'
|
||||
import { Theme } from '@types'
|
||||
|
||||
const Footer = dynamic(() => import('@components/Footer'))
|
||||
const Navbar = dynamic(() => import('@components/Navbar'))
|
||||
const Modal = dynamic(() => import('@components/Modal'))
|
||||
|
||||
import '../app.css'
|
||||
import '../github-markdown.css'
|
||||
import 'rc-tooltip/assets/bootstrap_white.css'
|
||||
import '@fortawesome/fontawesome-free/css/all.css'
|
||||
import PlatformDisplay from '@components/PlatformDisplay'
|
||||
|
||||
// Progress Bar
|
||||
NProgress.configure({ showSpinner: false })
|
||||
Router.events.on('routeChangeStart', NProgress.start)
|
||||
Router.events.on('routeChangeComplete', NProgress.done)
|
||||
Router.events.on('routeChangeError', NProgress.done)
|
||||
|
||||
const KoreanbotsApp = ({ Component, pageProps, err, cookie }: KoreanbotsProps): JSX.Element => {
|
||||
const [ shortcutModal, setShortcutModal ] = useState(false)
|
||||
const [ theme, setTheme ] = useState<Theme>('system')
|
||||
const [ standalone, setStandalone ] = useState(false)
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
console.log(
|
||||
'%c' + 'KOREANBOTS',
|
||||
'color: #3366FF; -webkit-text-stroke: 2px black; font-size: 72px; font-weight: bold;'
|
||||
)
|
||||
console.log(
|
||||
'%c' + '이곳에 코드를 붙여넣으면 공격자에게 엑세스 토큰을 넘겨줄 수 있습니다!!',
|
||||
'color: #ff0000; font-size: 20px; font-weight: bold;'
|
||||
)
|
||||
if (!localStorage.theme) {
|
||||
Logger.debug(`[THEME] ${systemTheme().toUpperCase()} THEME DETECTED`)
|
||||
setTheme(systemTheme())
|
||||
}
|
||||
else setTheme(localStorage.theme)
|
||||
setStandalone(handlePWA())
|
||||
|
||||
if('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/sw.js')
|
||||
} else Logger.warn('[SW] Load Failed')
|
||||
}, [])
|
||||
|
||||
return <div className={theme}>
|
||||
<DefaultSeo
|
||||
titleTemplate='%s - 한국 디스코드봇 리스트'
|
||||
defaultTitle={TITLE}
|
||||
description={DESCRIPTION}
|
||||
openGraph={{
|
||||
type: 'website',
|
||||
title: TITLE,
|
||||
url: 'https://koreanbots.dev',
|
||||
site_name: TITLE,
|
||||
description: DESCRIPTION,
|
||||
images: [
|
||||
{
|
||||
url: '/logo.png',
|
||||
width: 300,
|
||||
height: 300,
|
||||
alt: 'Logo'
|
||||
}
|
||||
]
|
||||
}}
|
||||
twitter={{
|
||||
site: '@koreanbots',
|
||||
handle: '@koreanbots',
|
||||
cardType: 'summary'
|
||||
}}
|
||||
/>
|
||||
<Head>
|
||||
{/* META */}
|
||||
<meta charSet='utf-8' />
|
||||
<meta httpEquiv='X-UA-Compatible' content='IE=edge' />
|
||||
<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 */}
|
||||
<meta name='theme-color' content={THEME_COLOR} />
|
||||
<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 */}
|
||||
<meta name='application-name' content={TITLE} />
|
||||
<meta name='msapplication-tooltip' content={DESCRIPTION} />
|
||||
<meta name='msapplication-starturl' content='/' />
|
||||
|
||||
{/* Tap highlighting */}
|
||||
<meta name='msapplication-tap-highlight' content='no' />
|
||||
|
||||
{/* UC Mobile Browser */}
|
||||
<meta name='full-screen' content='yes' />
|
||||
<meta name='browsermode' content='application' />
|
||||
|
||||
<meta name='nightmode' content='disable' />
|
||||
<meta name='layoutmode' content='fitscreen' />
|
||||
<meta name='imagemode' content='force' />
|
||||
<meta name='screen-orientation' content='portrait' />
|
||||
|
||||
</Head>
|
||||
<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'>
|
||||
<Component {...pageProps} err={err} theme={theme} setTheme={setTheme} pwa={standalone} />
|
||||
</div>
|
||||
{
|
||||
!(router.pathname.startsWith('/developers')) && <Footer theme={theme} setTheme={setTheme} />
|
||||
}
|
||||
<Modal full isOpen={shortcutModal} onClose={() => setShortcutModal(false)} dark={theme === 'dark'} header='단축키 안내'>
|
||||
<div className='px-3 h-80'>
|
||||
<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>
|
||||
</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) => {
|
||||
const appProps = await App.getInitialProps(appCtx)
|
||||
const parsed = parseCookie(appCtx.ctx.req)
|
||||
return {
|
||||
...appProps,
|
||||
cookie: parsed
|
||||
}
|
||||
}
|
||||
|
||||
export default KoreanbotsApp
|
||||
|
||||
interface KoreanbotsProps extends AppProps {
|
||||
err: unknown
|
||||
cookie: {
|
||||
token?: string
|
||||
}
|
||||
}
|
||||
90
pages/_document.tsx
Normal file
90
pages/_document.tsx
Normal file
@ -0,0 +1,90 @@
|
||||
import { TITLE } from '@utils/Constants'
|
||||
import Document, { DocumentContext, Html, Head, Main, NextScript } from 'next/document'
|
||||
|
||||
class MyDocument extends Document {
|
||||
static async getInitialProps(ctx: DocumentContext) {
|
||||
const initialProps = await Document.getInitialProps(ctx)
|
||||
|
||||
return initialProps
|
||||
}
|
||||
render() {
|
||||
return (
|
||||
<Html lang='ko-KR'>
|
||||
<Head>
|
||||
{/* LINK */}
|
||||
<link rel='manifest' href='/manifest.json' />
|
||||
<link rel='search' type='application/opensearchdescription+xml' title={TITLE} href='/opensearch.xml' />
|
||||
<link
|
||||
rel='stylesheet'
|
||||
href='//cdnjs.cloudflare.com/ajax/libs/highlight.js/10.6.0/styles/solarized-dark.min.css'
|
||||
/>
|
||||
<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='16x16' href='/favicon-16x16.png' />
|
||||
|
||||
{/* iOS */}
|
||||
<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='72x72' href='/static/apple-icon-72x72.png' />
|
||||
<link rel='apple-touch-icon' sizes='76x76' href='/static/apple-icon-76x76.png' />
|
||||
<link rel='apple-touch-icon' sizes='114x114' href='/static/apple-icon-114x114.png' />
|
||||
<link rel='apple-touch-icon' sizes='120x120' href='/static/apple-icon-120x120.png' />
|
||||
<link rel='apple-touch-icon' sizes='144x144' href='/static/apple-icon-144x144.png' />
|
||||
<link rel='apple-touch-icon' sizes='152x152' href='/static/apple-icon-152x152.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='512x512' href='/static/apple-icon-512x512.png' />
|
||||
<link rel='apple-touch-startup-image' href='/static/iphone5_splash.png' media='(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2)' />
|
||||
<link rel='apple-touch-startup-image' href='/static/iphone6_splash.png' media='(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2)' />
|
||||
<link rel='apple-touch-startup-image' href='/static/iphoneplus_splash.png' media='(device-width: 621px) and (device-height: 1104px) and (-webkit-device-pixel-ratio: 3)' />
|
||||
<link rel='apple-touch-startup-image' href='/static/iphonex_splash.png' media='(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3)' />
|
||||
<link rel='apple-touch-startup-image' href='/static/iphonexr_splash.png' media='(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2)' />
|
||||
<link rel='apple-touch-startup-image' href='/static/iphonexsmax_splash.png' media='(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3)' />
|
||||
<link rel='apple-touch-startup-image' href='/static/ipad_splash.png' media='(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2)' />
|
||||
<link rel='apple-touch-startup-image' href='/static/ipadpro1_splash.png' media='(device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2)' />
|
||||
<link rel='apple-touch-startup-image' href='/static/ipadpro3_splash.png' media='(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2)' />
|
||||
<link rel='apple-touch-startup-image' href='/static/ipadpro2_splash.png' media='(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2)' />
|
||||
|
||||
{/* Android */}
|
||||
<link rel='icon' type='image/png' sizes='192x192' href='/static/android-icon-192x192.png' />
|
||||
|
||||
{/* Others */}
|
||||
<link rel='shortcut icon' href='/favicon.ico' />
|
||||
|
||||
{/* 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='https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js'
|
||||
></script>
|
||||
<script async src='https://www.googletagmanager.com/gtag/js?id=UA-165454387-1'></script>
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){window.dataLayer.push(arguments);}
|
||||
gtag('js', new Date());
|
||||
|
||||
gtag('config', 'UA-165454387-1', { 'optimize_id': 'OPT-NXSXXB5' });
|
||||
|
||||
if(/MSIE \\d|Trident.*rv:/.test(navigator.userAgent)) {
|
||||
window.location = 'microsoft-edge:' + window.location;
|
||||
setTimeout(function() {
|
||||
window.location = 'https://go.microsoft.com/fwlink/?linkid=2135547';
|
||||
}, 1);
|
||||
}
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
</Head>
|
||||
<body className='h-full text-black dark:text-gray-100 dark:bg-discord-dark bg-white overflow-x-hidden'>
|
||||
<Main />
|
||||
<NextScript />
|
||||
</body>
|
||||
</Html>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default MyDocument
|
||||
31
pages/_error.tsx
Normal file
31
pages/_error.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import { NextPage } from 'next'
|
||||
import Link from 'next/link'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { getRandom } from '@utils/Tools'
|
||||
import { ErrorMessage } from '@utils/Constants'
|
||||
|
||||
const Container = dynamic(() => import('@components/Container'))
|
||||
|
||||
const MyError: NextPage = () => {
|
||||
return <div
|
||||
className='flex items-center h-screen select-none px-20'
|
||||
>
|
||||
<Container>
|
||||
<h2 className='text-4xl font-semibold'>{getRandom(ErrorMessage)}</h2>
|
||||
<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>
|
||||
<div>
|
||||
<Link href='/discord'>
|
||||
<a target='_blank' rel='noreferrer' className='text-lg hover:opacity-80 cursor-pointer'>
|
||||
<i className='fab fa-discord' />
|
||||
</a>
|
||||
</Link>
|
||||
<a href='https://twitter.com/koreanbots' target='_blank' rel='noreferrer' className='text-lg ml-2 hover:opacity-80 cursor-pointer'>
|
||||
<i className='fab fa-twitter' />
|
||||
</a>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
}
|
||||
|
||||
export default MyError
|
||||
29
pages/_offline.tsx
Normal file
29
pages/_offline.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import { NextPage } from 'next'
|
||||
import Link from 'next/link'
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
const Container = dynamic(() => import('@components/Container'))
|
||||
|
||||
const MyError: NextPage = () => {
|
||||
return <div
|
||||
className='flex items-center h-screen select-none px-20'
|
||||
>
|
||||
<Container>
|
||||
<h2 className='text-4xl font-semibold'>인터넷이 끊어졌나봐요...</h2>
|
||||
<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>
|
||||
<div>
|
||||
<Link href='/discord'>
|
||||
<a target='_blank' rel='noreferrer' className='text-lg hover:opacity-80 cursor-pointer'>
|
||||
<i className='fab fa-discord' />
|
||||
</a>
|
||||
</Link>
|
||||
<a href='https://twitter.com/koreanbots' target='_blank' rel='noreferrer' className='text-lg ml-2 hover:opacity-80 cursor-pointer'>
|
||||
<i className='fab fa-twitter' />
|
||||
</a>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
}
|
||||
|
||||
export default MyError
|
||||
78
pages/about.tsx
Normal file
78
pages/about.tsx
Normal file
@ -0,0 +1,78 @@
|
||||
import { NextPage } from 'next'
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
import ColorCard from '@components/ColorCard'
|
||||
import Divider from '@components/Divider'
|
||||
import Docs from '@components/Docs'
|
||||
import Segment from '@components/Segment'
|
||||
import { ThemeColors } from '@utils/Constants'
|
||||
|
||||
const Container = dynamic(() => import('@components/Container'))
|
||||
|
||||
const About:NextPage = () => {
|
||||
return <div className='pb-10'>
|
||||
<Docs title='소개' header={<h1 className='font-black text-4xl dark:text-koreanbots-blue'>“국내 디스코드 봇을 한 곳에서.”</h1>} subheader='한국 디스코드봇 리스트에서 자신의 서버에 딱 맞는 봇을 찾아보세요.'>
|
||||
<Container>
|
||||
<div className='py-1'>
|
||||
<h1 className='font-bold text-5xl my-5'>소개</h1>
|
||||
<p className='text-lg'><span className='text-koreanbots-blue font-bold'>한국 디스코드봇 리스트</span>는 본인의 봇을 직접 등록하고, 봇이 필요한 유저는 필요한 봇을 카테고리별로 확인할 수 있는 플랫폼입니다.</p>
|
||||
<p className='text-lg'>자신의 봇을 등록하거나 필요한 봇을 찾아보세요!</p>
|
||||
<Divider />
|
||||
<h1 className='font-bold text-5xl my-5'>특징</h1>
|
||||
<div className='grid md:grid-cols-3 gap-12 px-4 pb-5'>
|
||||
<div className='mx-auto font-normal'>
|
||||
<h2 className='text-3xl mb-1 font-bold text-koreanbots-blue'>하트 시스템</h2>
|
||||
<p className='text-base'>유용한 봇에 투표하는 하트 시스템으로 여러 유용한 봇이 상단에 노출될 수 있는 기회를 제공합니다.</p>
|
||||
</div>
|
||||
<div className='mx-auto font-normal'>
|
||||
<h2 className='text-3xl mb-1 font-bold text-koreanbots-blue'>인증 시스템</h2>
|
||||
<p className='text-base'>디스코드 봇 인증보다 한 단계 까다로운 기준을 적용하여, 이용자분들에게 신뢰감을 줍니다.</p>
|
||||
</div>
|
||||
<div className='mx-auto font-normal'>
|
||||
<h2 className='text-3xl mb-1 font-bold text-koreanbots-blue'>API 제공</h2>
|
||||
<p className='text-base'>봇 정보부터, 유저 투표 여부 확인, 봇의 svg 라벨까지.<br />다양한 API를 제공하여 커스텀할 수 있습니다!</p>
|
||||
</div>
|
||||
</div>
|
||||
<Divider />
|
||||
<h1 className='font-bold text-5xl my-5'>브랜드</h1>
|
||||
<h2 className='font-semibold text-3xl mb-7'>슬로건</h2>
|
||||
<Segment>
|
||||
<h2 className='font-semibold text-xl py-10 text-center'>
|
||||
<i className='fas fa-quote-left text-xs align-top' />
|
||||
국내 디스코드봇을 한 곳에서.
|
||||
<i className='fas fa-quote-right text-xs align-bottom' />
|
||||
</h2>
|
||||
</Segment>
|
||||
<Divider className='mt-7' />
|
||||
<h2 className='font-semibold text-3xl my-7'>로고</h2>
|
||||
<Segment>
|
||||
<>
|
||||
로고를 수정하거나, 변경, 왜곡 등 기타 다른 방법으로 로고를 수정하지 말아주세요.
|
||||
<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>
|
||||
<h3 className='font-bold text-xl my-1'>폰트</h3>
|
||||
<p className='font-bold text-md my-1'>영문: Uni Sans Heavy | 한글: Gugi</p>
|
||||
</>
|
||||
</Segment>
|
||||
<Divider className='mt-7' />
|
||||
<h2 className='font-semibold text-3xl my-5'>색상</h2>
|
||||
<div className='grid md:grid-cols-2 lg:grid-cols-4 gap-4'>
|
||||
{
|
||||
ThemeColors.map(el => (
|
||||
<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'}`} />
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</Docs>
|
||||
</div>
|
||||
}
|
||||
|
||||
export default About
|
||||
222
pages/addbot.tsx
Normal file
222
pages/addbot.tsx
Normal file
@ -0,0 +1,222 @@
|
||||
import { NextPage, NextPageContext } from 'next'
|
||||
import { useRef, useState } from 'react'
|
||||
import { useRouter } from 'next/router'
|
||||
import dynamic from 'next/dynamic'
|
||||
import Link from 'next/link'
|
||||
import { NextSeo } from 'next-seo'
|
||||
import { Form, Formik } from 'formik'
|
||||
import HCaptcha from '@hcaptcha/react-hcaptcha'
|
||||
|
||||
import { get } from '@utils/Query'
|
||||
import { cleanObject, parseCookie, redirectTo } from '@utils/Tools'
|
||||
import { AddBotSubmit, AddBotSubmitSchema } from '@utils/Yup'
|
||||
import { categories, library } from '@utils/Constants'
|
||||
import { getToken } from '@utils/Csrf'
|
||||
import Fetch from '@utils/Fetch'
|
||||
import { ResponseProps, SubmittedBot, Theme, User } from '@types'
|
||||
|
||||
const CheckBox = dynamic(() => import('@components/Form/CheckBox'))
|
||||
const Label = dynamic(() => import('@components/Form/Label'))
|
||||
const Login = dynamic(() => import('@components/Login'))
|
||||
const Input = dynamic(() => import('@components/Form/Input'))
|
||||
const Divider = dynamic(() => import('@components/Divider'))
|
||||
const TextArea = dynamic(() => import('@components/Form/TextArea'))
|
||||
const Segment = dynamic(() => import('@components/Segment'))
|
||||
const Markdown = dynamic(() => import('@components/Markdown'))
|
||||
const Select = dynamic(() => import('@components/Form/Select'))
|
||||
const Selects = dynamic(() => import('@components/Form/Selects'))
|
||||
const Button = dynamic(() => import('@components/Button'))
|
||||
const Container = dynamic(() => import('@components/Container'))
|
||||
const Message = dynamic(() => import('@components/Message'))
|
||||
const Captcha = dynamic(() => import('@components/Captcha'))
|
||||
|
||||
const AddBot:NextPage<AddBotProps> = ({ logged, user, csrfToken, theme }) => {
|
||||
const [ data, setData ] = useState<ResponseProps<SubmittedBot>>(null)
|
||||
const [ captcha, setCaptcha ] = useState(false)
|
||||
const [ touchedSumbit, setTouched ] = useState(false)
|
||||
const captchaRef = useRef<HCaptcha>()
|
||||
const router = useRouter()
|
||||
const initialValues: AddBotSubmit = {
|
||||
agree: false,
|
||||
id: '',
|
||||
prefix: '',
|
||||
library: '',
|
||||
category: [],
|
||||
intro: '',
|
||||
desc: `<!-- 이 설명을 지우시고 원하시는 설명을 적으셔도 좋습니다! -->
|
||||
# 봇이름
|
||||
자신의 봇을 자유롭게 표현해보세요!
|
||||
|
||||
## ✏️ 소개
|
||||
|
||||
무엇이 목적인 봇인가요?
|
||||
|
||||
## 🛠️ 기능
|
||||
|
||||
- 어떤
|
||||
- 기능
|
||||
- 있나요?`,
|
||||
_csrf: csrfToken,
|
||||
_captcha: 'captcha'
|
||||
}
|
||||
|
||||
function toLogin() {
|
||||
localStorage.redirectTo = window.location.href
|
||||
redirectTo(router, 'login')
|
||||
}
|
||||
|
||||
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})) })
|
||||
setData(res)
|
||||
}
|
||||
|
||||
if(!logged) return <Login>
|
||||
<NextSeo title='새로운 봇 추가하기' description='자신의 봇을 한국 디스코드봇 리스트에 등록하세요.' />
|
||||
</Login>
|
||||
return <Container paddingTop className='py-5'>
|
||||
<NextSeo title='새로운 봇 추가하기' description='자신의 봇을 한국 디스코드봇 리스트에 등록하세요.' />
|
||||
<h1 className='text-3xl font-bold'>새로운 봇 추가하기</h1>
|
||||
<div className='mt-1 mb-5'>
|
||||
안녕하세요, <span className='font-semibold'>{user.username}#{user.tag}</span>님! <a role='button' tabIndex={0} onKeyDown={toLogin} onClick={toLogin} className='text-discord-blurple cursor-pointer outline-none'>본인이 아니신가요?</a>
|
||||
</div>
|
||||
{
|
||||
data ? data.code == 200 && data.data ? <Message type='success'>
|
||||
<h2 className='text-lg font-black'>봇 신청 성공!</h2>
|
||||
<p>봇을 성공적으로 신청했습니다! 심사 페이지로 리다이랙트됩니다. {redirectTo(router, `/pendingBots/${data.data.id}/${data.data.date}`)}</p>
|
||||
</Message> : <Message type='error'>
|
||||
<h2 className='text-lg font-black'>{data.message || '오류가 발생했습니다.'}</h2>
|
||||
<ul className='list-disc list-inside'>
|
||||
{data.errors?.map((el, n) => <li key={n}>{el}</li>)}
|
||||
</ul>
|
||||
|
||||
</Message> : <></>
|
||||
}
|
||||
<Formik initialValues={initialValues}
|
||||
validationSchema={AddBotSubmitSchema}
|
||||
onSubmit={() => setCaptcha(true)}>
|
||||
{({ errors, touched, values, isValid, setFieldTouched, setFieldValue }) => (
|
||||
<Form>
|
||||
<div className='py-3'>
|
||||
<Message type='warning'>
|
||||
<h2 className='text-lg font-black'>신청하시기 전에 다음 사항을 확인해 주세요!</h2>
|
||||
<ul className='list-disc list-inside'>
|
||||
<li><Link href='/discord'><a rel='noreferrer' target='_blank' className='text-blue-500 hover:text-blue-600'>디스코드 서버</a></Link>에 참가하셨나요?</li>
|
||||
<li>봇이 <Link href='/guidelines'><a rel='noreferrer' target='_blank' className='text-blue-500 hover:text-blue-600'>가이드라인</a></Link>을 지키고 있나요?</li>
|
||||
<li>봇 소유자가 두 명 이상인가요? 봇 소유자는 봇이 승인된 뒤, 더 추가하실 수 있습니다.</li>
|
||||
<li>본인이 봇의 소유자라는 것을 증명할 수 있나요? 본인이 봇 소유자임을 증명하려면, 태그가 포함되어야 합니다.</li>
|
||||
다음 명령어(접두사로 시작하는) 중 하나 이상에 소유자를 표시하셔야 합니다. <br/>
|
||||
<strong>빗금 명렁어(Slash Command) 봇인 경우에도 적용됩니다.</strong> 빗금 명령어가 아닌 다음 일반 명령어가 작동해야합니다. (심사시에 빗금 명령어 권한이 따로 부여되지 않습니다.)
|
||||
<ul>
|
||||
<li>- 도움 명령어: 도움, 도움말, 명령어, help, commands</li>
|
||||
<li>- 도움 명령어에 소유자임을 나타내고 싶지 않으시다면, 아래 명령어를 만들어주세요<br/>
|
||||
명령어: [접두사]hellothisisverification 응답: 유저#태그(아이디)</li>
|
||||
</ul>
|
||||
<li>또한, 봇을 등록하게 되면 작성하신 모든 정보는 웹과 API에 공개됩니다.</li>
|
||||
</ul>
|
||||
</Message>
|
||||
</div>
|
||||
<Label For='agree' error={errors.agree && touched.agree ? errors.agree : null} grid={false}>
|
||||
<div className='flex items-center'>
|
||||
<CheckBox name='agree' />
|
||||
<strong className='text-sm ml-2'>해당 내용을 숙지하였으며, 모두 이행하였고 위 내용에 해당하는 거부 사유는 답변받지 않는다는 점을 이해합니다.</strong>
|
||||
</div>
|
||||
</Label>
|
||||
<Divider />
|
||||
<Label For='id' label='봇 ID' labelDesc='봇의 클라이언트 ID를 의미합니다.' error={errors.id && touched.id ? errors.id : null} short required>
|
||||
<Input name='id' placeholder='653534001742741552' />
|
||||
</Label>
|
||||
<Label For='prefix' label='접두사' labelDesc='봇의 사용시 앞 쪽에 붙은 기호를 의미합니다. (Prefix)' error={errors.prefix && touched.prefix ? errors.prefix : null} short required>
|
||||
<Input name='prefix' placeholder='!' />
|
||||
</Label>
|
||||
<Label 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 For='category' label='카테고리' labelDesc='봇에 해당되는 카테고리를 선택해주세요' required error={errors.category && touched.category ? errors.category as string : null}>
|
||||
<Selects options={categories.map(el=> ({ label: el, value: el }))} handleChange={(value) => {
|
||||
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>
|
||||
<Divider />
|
||||
<Label For='website' label='웹사이트' labelDesc='봇의 웹사이트를 작성해주세요.' error={errors.website && touched.website ? errors.website : null}>
|
||||
<Input name='website' placeholder='https://koreanbots.dev' />
|
||||
</Label>
|
||||
<Label 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 For='inviteLink' 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='text-gray-400 mt-1 text-sm'>
|
||||
<Link href='/calculator'>
|
||||
<a rel='noreferrer' target='_blank' className='text-blue-500 hover:text-blue-400'>이곳</a>
|
||||
</Link>에서 초대링크를 생성하실 수 있습니다!
|
||||
</span>
|
||||
</Label>
|
||||
{
|
||||
values.category.includes('빗금 명령어') && <Message type='warning'>
|
||||
<h2 className='text-lg font-semibold'>해당 봇은 빗금 명령어(Slash Command) 카테고리가 선택되었습니다.</h2>
|
||||
<p>초대링크는 빗금 명령어 권한을 부여하지 않은 일반 봇 초대링크로 자동 생성됩니다.
|
||||
따라서 빗금 명령어 권한을 포함한 초대링크를 직접 설정해주세요.</p>
|
||||
</Message>
|
||||
}
|
||||
<Label For='discord' label='지원 디스코드 서버' labelDesc='봇의 지원 디스코드 서버를 입력해주세요. (봇에 대해 도움을 받을 수 있는 공간입니다.)' error={errors.discord && touched.discord ? errors.discord : null} short>
|
||||
<div className='flex items-center'>
|
||||
discord.gg/<Input name='discord' placeholder='JEh53MQ' />
|
||||
</div>
|
||||
</Label>
|
||||
<Divider />
|
||||
<Label For='intro' label='봇 소개' labelDesc='봇을 소개할 수 있는 간단한 설명을 적어주세요. (최대 60자)' error={errors.intro && touched.intro ? errors.intro : null} required>
|
||||
<Input name='intro' placeholder='국내 봇을 한 곳에서.' />
|
||||
</Label>
|
||||
<Label 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 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) => {
|
||||
submitBot(values, token)
|
||||
window.scrollTo({ top: 0 })
|
||||
setCaptcha(false)
|
||||
captchaRef?.current?.resetCaptcha()
|
||||
}} /> : <>
|
||||
{
|
||||
touchedSumbit && !isValid && <div className='my-1 text-red-500 text-xs font-light'>누락되거나 잘못된 항목이 있습니다. 다시 확인해주세요.</div>
|
||||
}
|
||||
<Button type='submit' onClick={() => {
|
||||
setTouched(true)
|
||||
if(!isValid) window.scrollTo({ top: 0 })
|
||||
} }>
|
||||
<>
|
||||
<i className='far fa-paper-plane'/> 제출
|
||||
</>
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</Container>
|
||||
}
|
||||
|
||||
export const getServerSideProps = async (ctx: NextPageContext) => {
|
||||
const parsed = parseCookie(ctx.req)
|
||||
const user = await get.Authorization(parsed?.token)
|
||||
return { props: { logged: !!user, user: await get.user.load(user || ''), csrfToken: getToken(ctx.req, ctx.res) } }
|
||||
}
|
||||
|
||||
interface AddBotProps {
|
||||
logged: boolean
|
||||
user: User
|
||||
csrfToken: string
|
||||
theme: Theme
|
||||
}
|
||||
|
||||
export default AddBot
|
||||
8
pages/api/[...404].ts
Normal file
8
pages/api/[...404].ts
Normal file
@ -0,0 +1,8 @@
|
||||
import ResponseWrapper from '@utils/ResponseWrapper'
|
||||
import RequestHandler from '@utils/RequestHandler'
|
||||
|
||||
const NotFound = RequestHandler().all(async (_req, res) => {
|
||||
return ResponseWrapper(res, { code: 404, message: '요청하신 URL에 페이지가 존재하지 않습니다.' })
|
||||
})
|
||||
|
||||
export default NotFound
|
||||
13
pages/api/_custom/429.ts
Normal file
13
pages/api/_custom/429.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import ResponseWrapper from '@utils/ResponseWrapper'
|
||||
import { NextApiHandler, NextApiRequest, NextApiResponse } from 'next'
|
||||
|
||||
const RateLimit: NextApiHandler = (_req: NextApiRequest, res: NextApiResponse) => {
|
||||
res.statusCode = 429
|
||||
return ResponseWrapper(res, {
|
||||
code: 429,
|
||||
message: '지정된 시간에 너무 많은 요청을 보냈습니다. 잠시 뒤에 시도해주세요.',
|
||||
errors: ['지정된 시간에 너무 많은 요청을 보냈습니다. 잠시 뒤에 시도해주세요.'],
|
||||
})
|
||||
}
|
||||
|
||||
export default RateLimit
|
||||
77
pages/api/auth/discord/callback.ts
Normal file
77
pages/api/auth/discord/callback.ts
Normal file
@ -0,0 +1,77 @@
|
||||
import { NextApiRequest } from 'next'
|
||||
import fetch from 'node-fetch'
|
||||
import { serialize } from 'cookie'
|
||||
|
||||
import { DiscordEnpoints } from '@utils/Constants'
|
||||
import { formData } from '@utils/Tools'
|
||||
import { OauthCallbackSchema } from '@utils/Yup'
|
||||
import ResponseWrapper from '@utils/ResponseWrapper'
|
||||
import { DiscordTokenInfo, DiscordUserInfo } from '@types'
|
||||
import { update } from '@utils/Query'
|
||||
import { verify } from '@utils/Jwt'
|
||||
import RequestHandler from '@utils/RequestHandler'
|
||||
|
||||
const Callback = RequestHandler().get(async (req: ApiRequest, res) => {
|
||||
const validate = await OauthCallbackSchema.validate(req.query)
|
||||
.then(r => r)
|
||||
.catch(e => {
|
||||
ResponseWrapper(res, { code: 400, errors: e.errors })
|
||||
return null
|
||||
})
|
||||
|
||||
if (!validate) return
|
||||
|
||||
res.statusCode = 200
|
||||
const token: DiscordTokenInfo = await fetch(DiscordEnpoints.Token, {
|
||||
method: 'POST',
|
||||
body: formData({
|
||||
client_id: process.env.DISCORD_CLIENT_ID,
|
||||
redirect_uri: process.env.KOREANBOTS_URL + '/api/auth/discord/callback',
|
||||
client_secret: process.env.DISCORD_CLIENT_SECRET,
|
||||
scope: process.env.DISCORD_SCOPE,
|
||||
grant_type: 'authorization_code',
|
||||
code: req.query.code,
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
}).then(r => r.json())
|
||||
if (token.error) return ResponseWrapper(res, { code: 400, errors: ['올바르지 않은 코드입니다.'] })
|
||||
|
||||
const user: DiscordUserInfo = await fetch(DiscordEnpoints.Me, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `${token.token_type} ${token.access_token}`,
|
||||
},
|
||||
}).then(r => r.json())
|
||||
|
||||
const userToken = await update.assignToken({
|
||||
id: user.id,
|
||||
access_token: token.access_token,
|
||||
expires_in: token.expires_in,
|
||||
refresh_token: token.refresh_token,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
discriminator: user.discriminator,
|
||||
})
|
||||
const info = verify(userToken)
|
||||
res.setHeader(
|
||||
'set-cookie',
|
||||
serialize('token', userToken, {
|
||||
expires: new Date(info.exp * 1000),
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
})
|
||||
)
|
||||
res.redirect(301, '/callback/discord')
|
||||
})
|
||||
|
||||
interface ApiRequest extends NextApiRequest {
|
||||
query: {
|
||||
code: string
|
||||
}
|
||||
}
|
||||
|
||||
export default Callback
|
||||
12
pages/api/auth/discord/index.ts
Normal file
12
pages/api/auth/discord/index.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { generateOauthURL } from '@utils/Tools'
|
||||
import RequestHandler from '@utils/RequestHandler'
|
||||
|
||||
const Discord = RequestHandler().get(async (_req: NextApiRequest, res: NextApiResponse) => {
|
||||
res.redirect(
|
||||
301,
|
||||
generateOauthURL('discord', process.env.DISCORD_CLIENT_ID, process.env.DISCORD_SCOPE)
|
||||
)
|
||||
})
|
||||
|
||||
export default Discord
|
||||
15
pages/api/auth/discord/logout.ts
Normal file
15
pages/api/auth/discord/logout.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { serialize } from 'cookie'
|
||||
import RequestHandler from '@utils/RequestHandler'
|
||||
|
||||
const Logout = RequestHandler().get(async (req, res) => {
|
||||
res.setHeader('Cache-control', 'no-cache')
|
||||
res.setHeader(
|
||||
'set-cookie',
|
||||
serialize('token', '', {
|
||||
maxAge: -1,
|
||||
path: '/',
|
||||
})
|
||||
)
|
||||
res.redirect(301, '/')
|
||||
})
|
||||
export default Logout
|
||||
48
pages/api/auth/github/callback.ts
Normal file
48
pages/api/auth/github/callback.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import { NextApiRequest } from 'next'
|
||||
import fetch from 'node-fetch'
|
||||
|
||||
import { GithubTokenInfo } from '@types'
|
||||
import { SpecialEndPoints } from '@utils/Constants'
|
||||
import { OauthCallbackSchema } from '@utils/Yup'
|
||||
import ResponseWrapper from '@utils/ResponseWrapper'
|
||||
import { get, update } from '@utils/Query'
|
||||
import RequestHandler from '@utils/RequestHandler'
|
||||
|
||||
const Callback = RequestHandler().get(async (req: ApiRequest, res) => {
|
||||
const validate = await OauthCallbackSchema.validate(req.query)
|
||||
.then(r => r)
|
||||
.catch(e => {
|
||||
ResponseWrapper(res, { code: 400, errors: e.errors })
|
||||
return null
|
||||
})
|
||||
|
||||
if (!validate) return
|
||||
|
||||
const user = await get.Authorization(req.cookies.token)
|
||||
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), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json'
|
||||
},
|
||||
}).then(r => r.json())
|
||||
if (token.error) return ResponseWrapper(res, { code: 400, errors: ['올바르지 않은 코드입니다.'] })
|
||||
|
||||
const github: { login: string } = await fetch(SpecialEndPoints.Github.Me, {
|
||||
headers: {
|
||||
Authorization: `token ${token.access_token}`
|
||||
}
|
||||
}).then(r => r.json())
|
||||
const result = await update.Github(user, github.login)
|
||||
if(result === 0) return ResponseWrapper(res, { code: 400, message: '이미 등록되어있는 깃허브 계정입니다.' })
|
||||
get.user.clear(user)
|
||||
res.redirect(301, '/panel')
|
||||
})
|
||||
|
||||
interface ApiRequest extends NextApiRequest {
|
||||
query: {
|
||||
code: string
|
||||
}
|
||||
}
|
||||
|
||||
export default Callback
|
||||
30
pages/api/auth/github/index.ts
Normal file
30
pages/api/auth/github/index.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { generateOauthURL } from '@utils/Tools'
|
||||
import RequestHandler from '@utils/RequestHandler'
|
||||
import ResponseWrapper from '@utils/ResponseWrapper'
|
||||
import { get, update } from '@utils/Query'
|
||||
import { checkToken } from '@utils/Csrf'
|
||||
|
||||
const Github = RequestHandler().get(async (_req: NextApiRequest, res: NextApiResponse) => {
|
||||
res.redirect(
|
||||
301,
|
||||
generateOauthURL('github', process.env.GITHUB_CLIENT_ID)
|
||||
)
|
||||
})
|
||||
.delete(async (req: DeleteApiRequest, res) => {
|
||||
const user = await get.Authorization(req.cookies.token)
|
||||
if (!user) return ResponseWrapper(res, { code: 401 })
|
||||
const csrfValidated = checkToken(req, res, req.body._csrf)
|
||||
if(!csrfValidated) return
|
||||
await update.Github(user, null)
|
||||
get.user.clear(user)
|
||||
return ResponseWrapper(res, { code: 200 })
|
||||
})
|
||||
|
||||
interface DeleteApiRequest extends NextApiRequest {
|
||||
body: {
|
||||
_csrf: string
|
||||
}
|
||||
}
|
||||
|
||||
export default Github
|
||||
64
pages/api/image/discord/avatars/[id].ts
Normal file
64
pages/api/image/discord/avatars/[id].ts
Normal file
@ -0,0 +1,64 @@
|
||||
import { NextApiRequest } from 'next'
|
||||
import rateLimit from 'express-rate-limit'
|
||||
|
||||
import ResponseWrapper from '@utils/ResponseWrapper'
|
||||
import { DiscordEnpoints } from '@utils/Constants'
|
||||
import { get } from '@utils/Query'
|
||||
import { ImageOptionsSchema } from '@utils/Yup'
|
||||
import RequestHandler from '@utils/RequestHandler'
|
||||
|
||||
const rateLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
max: 150,
|
||||
handler: async (_req, res) => {
|
||||
const img = await get.images.user.load(DiscordEnpoints.CDN.default(Math.floor(Math.random() * 6), { format: 'png' }))
|
||||
res.setHeader('Content-Type', 'image/png')
|
||||
res.setHeader('Cache-Control', 'no-cache')
|
||||
res.send(img)
|
||||
},
|
||||
keyGenerator: (req) => req.headers['x-forwarded-for'] as string,
|
||||
skip: (_req, res) => {
|
||||
res.removeHeader('X-RateLimit-Global')
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
const Avatar = RequestHandler()
|
||||
.get(rateLimiter)
|
||||
.get(async(req: ApiRequest, res) => {
|
||||
res.setHeader('Access-Control-Allow-Origin', process.env.KOREANBOTS_URL)
|
||||
const { id: param, size='256' } = req.query
|
||||
const splitted = param.split('.')
|
||||
let ext = splitted[1]
|
||||
const id = splitted[0]
|
||||
const validated = await ImageOptionsSchema.validate({ id, ext, size }, { abortEarly: false }).then(el=> el).catch(e=> {
|
||||
ResponseWrapper(res, { code: 400, errors: e.errors })
|
||||
return null
|
||||
})
|
||||
if(!validated) return
|
||||
|
||||
const user = await get.discord.user.load(id)
|
||||
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 }))
|
||||
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'
|
||||
}
|
||||
|
||||
|
||||
res.setHeader('Content-Type', `image/${ext}`)
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400')
|
||||
res.send(img)
|
||||
})
|
||||
|
||||
|
||||
|
||||
interface ApiRequest extends NextApiRequest {
|
||||
query: {
|
||||
id: string
|
||||
size?: '128' | '256' | '512'
|
||||
}
|
||||
}
|
||||
|
||||
export default Avatar
|
||||
1
pages/api/index.ts
Normal file
1
pages/api/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './[...404]'
|
||||
12
pages/api/v1/[[...deprecated]].ts
Normal file
12
pages/api/v1/[[...deprecated]].ts
Normal file
@ -0,0 +1,12 @@
|
||||
import ResponseWrapper from '@utils/ResponseWrapper'
|
||||
import RequestHandler from '@utils/RequestHandler'
|
||||
|
||||
const Deprecated = RequestHandler().all(async (_req, res) => {
|
||||
return ResponseWrapper(res, {
|
||||
code: 406,
|
||||
message: '해당 API 버전은 지원 종료되었습니다.',
|
||||
version: 1,
|
||||
})
|
||||
})
|
||||
|
||||
export default Deprecated
|
||||
51
pages/api/v1/bots/servers.ts
Normal file
51
pages/api/v1/bots/servers.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import { NextApiRequest} from 'next'
|
||||
import rateLimit from 'express-rate-limit'
|
||||
|
||||
import { get, update } from '@utils/Query'
|
||||
import RequestHandler from '@utils/RequestHandler'
|
||||
import ResponseWrapper from '@utils/ResponseWrapper'
|
||||
import { BotStatUpdate, BotStatUpdateSchema } from '@utils/Yup'
|
||||
|
||||
const limiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
max: 1,
|
||||
statusCode: 429,
|
||||
handler: (_req, res) => ResponseWrapper(res, { code: 429, version: 1 }),
|
||||
keyGenerator: (req) => req.headers.authorization,
|
||||
skip: (req, res) => {
|
||||
res.removeHeader('X-RateLimit-Global')
|
||||
if(!req.headers.authorization) return true
|
||||
else return false
|
||||
}
|
||||
})
|
||||
|
||||
const BotStats = RequestHandler()
|
||||
.post(limiter)
|
||||
.post(async (req: PostApiRequest, res) => {
|
||||
const bot = await get.BotAuthorization(req.headers.token)
|
||||
if(!bot) return ResponseWrapper(res, { code: 401, version: 1 })
|
||||
const validated: BotStatUpdate = await BotStatUpdateSchema.validate(req.body, { abortEarly: false })
|
||||
.then(el => el)
|
||||
.catch(e => {
|
||||
ResponseWrapper(res, { code: 400, errors: e.errors })
|
||||
return null
|
||||
})
|
||||
|
||||
if(!validated) return
|
||||
const botInfo = await get.bot.load(bot)
|
||||
if(!botInfo) 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)
|
||||
if(d===1 || d===2) return ResponseWrapper(res, { code: 403, message: `서버 수를 ${[null, '1만', '100만'][d]} 이상으로 설정하실 수 없습니다. 문의해주세요.`, version: 1 })
|
||||
return ResponseWrapper(res, { code: 200, message: '성공적으로 업데이트 했습니다.', version: 1 })
|
||||
})
|
||||
|
||||
|
||||
interface PostApiRequest extends NextApiRequest {
|
||||
headers: {
|
||||
token: string
|
||||
}
|
||||
body: BotStatUpdate
|
||||
}
|
||||
|
||||
export default BotStats
|
||||
27
pages/api/v1/bots/voted/[id].ts
Normal file
27
pages/api/v1/bots/voted/[id].ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { NextApiRequest } from 'next'
|
||||
import RequestHandler from '@utils/RequestHandler'
|
||||
import { get } from '@utils/Query'
|
||||
import ResponseWrapper from '@utils/ResponseWrapper'
|
||||
import Yup from '@utils/Yup'
|
||||
import { VOTE_COOLDOWN } from '@utils/Constants'
|
||||
|
||||
const BotVoted = RequestHandler()
|
||||
.get(async (req: ApiRequest, res) => {
|
||||
const bot = await get.BotAuthorization(req.headers.token)
|
||||
if(!bot) return ResponseWrapper(res, { code: 401, version: 1 })
|
||||
const userID = await Yup.string().required().validate(bot).then(el => el).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 {
|
||||
headers: {
|
||||
token: string
|
||||
}
|
||||
query: {
|
||||
id: string
|
||||
}
|
||||
}
|
||||
|
||||
export default BotVoted
|
||||
38
pages/api/v2/applications/bots/[id]/index.ts
Normal file
38
pages/api/v2/applications/bots/[id]/index.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { NextApiRequest } from 'next'
|
||||
|
||||
import { DeveloperBot, DeveloperBotSchema } from '@utils/Yup'
|
||||
import { get, update } from '@utils/Query'
|
||||
import ResponseWrapper from '@utils/ResponseWrapper'
|
||||
import { checkToken } from '@utils/Csrf'
|
||||
import RequestHandler from '@utils/RequestHandler'
|
||||
|
||||
import { User } from '@types'
|
||||
|
||||
const BotApplications = RequestHandler().patch(async (req: ApiRequest, res) => {
|
||||
const user = await get.Authorization(req.cookies.token)
|
||||
if (!user) return ResponseWrapper(res, { code: 401 })
|
||||
const csrfValidated = checkToken(req, res, req.body._csrf)
|
||||
if (!csrfValidated) return
|
||||
const validated = await DeveloperBotSchema.validate(req.body, { abortEarly: false })
|
||||
.then(el => el)
|
||||
.catch(e => {
|
||||
ResponseWrapper(res, { code: 400, errors: e.errors })
|
||||
return null
|
||||
})
|
||||
|
||||
if (!validated) return
|
||||
const bot = await get.bot.load(req.query.id)
|
||||
if (!bot) return ResponseWrapper(res, { code: 404, message: '존재하지 않는 봇입니다.' })
|
||||
if (!(bot.owners as User[]).find(el => el.id === user)) return ResponseWrapper(res, { code: 403 })
|
||||
await update.updateBotApplication(req.query.id, { webhook: validated.webhook || null })
|
||||
return ResponseWrapper(res, { code: 200 })
|
||||
})
|
||||
|
||||
interface ApiRequest extends NextApiRequest {
|
||||
body: DeveloperBot
|
||||
query: {
|
||||
id: string
|
||||
}
|
||||
}
|
||||
|
||||
export default BotApplications
|
||||
39
pages/api/v2/applications/bots/[id]/reset.ts
Normal file
39
pages/api/v2/applications/bots/[id]/reset.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { NextApiRequest } from 'next'
|
||||
|
||||
import { ResetBotToken, ResetBotTokenSchema } from '@utils/Yup'
|
||||
import { get, update } from '@utils/Query'
|
||||
import ResponseWrapper from '@utils/ResponseWrapper'
|
||||
import { checkToken } from '@utils/Csrf'
|
||||
import RequestHandler from '@utils/RequestHandler'
|
||||
|
||||
import { User } from '@types'
|
||||
|
||||
const ResetApplication = RequestHandler().post(async (req: ApiRequest, res) => {
|
||||
const user = await get.Authorization(req.cookies.token)
|
||||
if (!user) return ResponseWrapper(res, { code: 401 })
|
||||
const csrfValidated = checkToken(req, res, req.body._csrf)
|
||||
if (!csrfValidated) return
|
||||
const validated = await ResetBotTokenSchema.validate(req.body, { abortEarly: false })
|
||||
.then(el => el)
|
||||
.catch(e => {
|
||||
ResponseWrapper(res, { code: 400, errors: e.errors })
|
||||
return null
|
||||
})
|
||||
|
||||
if (!validated) return
|
||||
const bot = await get.bot.load(req.query.id)
|
||||
if (!bot) return ResponseWrapper(res, { code: 404, message: '존재하지 않는 봇입니다.' })
|
||||
if (!(bot.owners as User[]).find(el => el.id === user)) return ResponseWrapper(res, { code: 403 })
|
||||
const d = await update.resetBotToken(req.query.id, validated.token)
|
||||
if (!d) return ResponseWrapper(res, { code: 500, message: '무언가 잘못되었습니다.' })
|
||||
return ResponseWrapper(res, { code: 200, data: { token: d } })
|
||||
})
|
||||
|
||||
interface ApiRequest extends NextApiRequest {
|
||||
body: ResetBotToken
|
||||
query: {
|
||||
id: string
|
||||
}
|
||||
}
|
||||
|
||||
export default ResetApplication
|
||||
171
pages/api/v2/bots/[id]/index.ts
Normal file
171
pages/api/v2/bots/[id]/index.ts
Normal file
@ -0,0 +1,171 @@
|
||||
import { NextApiRequest } from 'next'
|
||||
import rateLimit from 'express-rate-limit'
|
||||
import { MessageEmbed } from 'discord.js'
|
||||
|
||||
import { CaptchaVerify, get, put, remove, update } from '@utils/Query'
|
||||
import ResponseWrapper from '@utils/ResponseWrapper'
|
||||
import { checkToken } from '@utils/Csrf'
|
||||
import { AddBotSubmit, AddBotSubmitSchema, CsrfCaptcha, ManageBot, ManageBotSchema } from '@utils/Yup'
|
||||
import RequestHandler from '@utils/RequestHandler'
|
||||
import { User } from '@types'
|
||||
import { checkUserFlag, diff, inspect, makeDiscordCodeblock, objectDiff, serialize } from '@utils/Tools'
|
||||
import { discordLog, getBotReviewLogChannel } from '@utils/DiscordBot'
|
||||
import { KoreanbotsEndPoints } from '@utils/Constants'
|
||||
|
||||
const patchLimiter = rateLimit({
|
||||
windowMs: 2 * 60 * 1000,
|
||||
max: 2,
|
||||
handler: (_req, res) => ResponseWrapper(res, { code: 429 }),
|
||||
keyGenerator: (req) => req.headers['x-forwarded-for'] as string,
|
||||
skip: (_req, res) => {
|
||||
res.removeHeader('X-RateLimit-Global')
|
||||
return false
|
||||
}
|
||||
})
|
||||
const Bots = RequestHandler()
|
||||
.get(async (req: GetApiRequest, res) => {
|
||||
const bot = await get.bot.load(req.query.id)
|
||||
if (!bot) return ResponseWrapper(res, { code: 404, message: '존재하지 않는 봇입니다.' })
|
||||
else return ResponseWrapper(res, { code: 200, data: bot })
|
||||
})
|
||||
.post(async (req: PostApiRequest, res) => {
|
||||
const user = await get.Authorization(req.cookies.token)
|
||||
if (!user) return ResponseWrapper(res, { code: 401 })
|
||||
const csrfValidated = checkToken(req, res, req.body._csrf)
|
||||
if (!csrfValidated) return
|
||||
|
||||
const validated = await AddBotSubmitSchema.validate(req.body, { abortEarly: false })
|
||||
.then(el => el)
|
||||
.catch(e => {
|
||||
ResponseWrapper(res, { code: 400, errors: e.errors })
|
||||
return null
|
||||
})
|
||||
|
||||
if (!validated) return
|
||||
if (validated.id !== req.query.id)
|
||||
return ResponseWrapper(res, { code: 400, errors: ['요청 주소와 Body의 정보가 다릅니다.'] })
|
||||
const captcha = await CaptchaVerify(validated._captcha)
|
||||
if(!captcha) return ResponseWrapper(res, { code: 400, message: '캡챠 검증에 실패하였습니다.' })
|
||||
const result = await put.submitBot(user, validated)
|
||||
if (result === 1)
|
||||
return ResponseWrapper(res, {
|
||||
code: 403,
|
||||
message: '이미 대기중인 봇이 있습니다.',
|
||||
errors: [
|
||||
'한 번에 최대 2개의 봇까지만 신청하실 수 있습니다.\n다른 봇들의 심사가 완료된 뒤에 신청해주세요.',
|
||||
],
|
||||
})
|
||||
else if (result === 2)
|
||||
return ResponseWrapper(res, {
|
||||
code: 406,
|
||||
message: '해당 봇은 이미 심사중이거나 이미 등록되어있습니다.',
|
||||
errors: [
|
||||
'해당 아이디의 봇은 이미 심사중이거나 등록되어있습니다. 본인 소유의 봇이고 신청하신 적이 없으시다면 문의해주세요.',
|
||||
],
|
||||
})
|
||||
else if (result === 3)
|
||||
return ResponseWrapper(res, {
|
||||
code: 404,
|
||||
message: '올바르지 않은 봇 아이디입니다.',
|
||||
errors: ['해당 아이디의 봇은 존재하지 않습니다. 다시 확인해주세요.'],
|
||||
})
|
||||
else if (result === 4)
|
||||
return ResponseWrapper(res, {
|
||||
code: 403,
|
||||
message: '디스코드 서버에 참가해주세요.',
|
||||
errors: ['봇 신청하시기 위해서는 공식 디스코드 서버에 참가해주셔야합니다.'],
|
||||
})
|
||||
get.botSubmits.clear(user)
|
||||
|
||||
await discordLog('BOT/SUBMIT', user, new MessageEmbed().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)
|
||||
await getBotReviewLogChannel().send(new MessageEmbed().setAuthor(`${userinfo.username}#${userinfo.tag}`, KoreanbotsEndPoints.URL.root + KoreanbotsEndPoints.CDN.avatar(userinfo.id, { format: 'png', size: 256 }), KoreanbotsEndPoints.URL.user(userinfo.id)).setTitle('대기 중').setColor('GREY').setDescription(`[${result.id}/${result.date}](${KoreanbotsEndPoints.URL.submittedBot(result.id, result.date)})`).setTimestamp())
|
||||
return ResponseWrapper(res, { code: 200, data: result })
|
||||
})
|
||||
.delete(async (req: DeleteApiRequest, res) => {
|
||||
const user = await get.Authorization(req.cookies.token)
|
||||
if (!user) return ResponseWrapper(res, { code: 401 })
|
||||
const bot = await get.bot.load(req.query.id)
|
||||
if(!bot) return ResponseWrapper(res, { code: 404, message: '존재하지 않는 봇입니다.' })
|
||||
if((bot.owners as User[])[0].id !== user) return ResponseWrapper(res, { code: 403 })
|
||||
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: ['오류라고 생각되면 문의해주세요.'] })
|
||||
const csrfValidated = checkToken(req, res, req.body._csrf)
|
||||
if (!csrfValidated) return
|
||||
const captcha = await CaptchaVerify(req.body._captcha)
|
||||
if(!captcha) return ResponseWrapper(res, { code: 400, message: '캡챠 검증에 실패하였습니다.' })
|
||||
if(req.body.name !== bot.name) return ResponseWrapper(res, { code: 400, message: '봇 이름을 입력해주세요.' })
|
||||
remove.bot(bot.id)
|
||||
get.user.clear(user)
|
||||
await discordLog('BOT/DELETE', user, (new MessageEmbed().setDescription(`${bot.name} - <@${bot.id}> ([${bot.id}](${KoreanbotsEndPoints.URL.bot(bot.id)}))`)),
|
||||
{
|
||||
content: inspect(bot),
|
||||
format: 'js'
|
||||
}
|
||||
)
|
||||
return ResponseWrapper(res, { code: 200, message: '성공적으로 삭제했습니다.' })
|
||||
})
|
||||
.patch(patchLimiter).patch(async (req: PatchApiRequest, res) => {
|
||||
const bot = await get.bot.load(req.query.id)
|
||||
if(!bot) return ResponseWrapper(res, { code: 404, message: '존재하지 않는 봇입니다.' })
|
||||
const user = await get.Authorization(req.cookies.token)
|
||||
if (!user) return ResponseWrapper(res, { code: 401 })
|
||||
const userInfo = await get.user.load(user)
|
||||
if(['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)
|
||||
if (!csrfValidated) return
|
||||
|
||||
const validated = await ManageBotSchema.validate(req.body, { abortEarly: false })
|
||||
.then(el => el)
|
||||
.catch(e => {
|
||||
ResponseWrapper(res, { code: 400, errors: e.errors })
|
||||
return null
|
||||
})
|
||||
|
||||
if (!validated) return
|
||||
const result = await update.bot(req.query.id, validated)
|
||||
if(result === 0) return ResponseWrapper(res, { code: 400 })
|
||||
else {
|
||||
get.bot.clear(req.query.id)
|
||||
const embed = new MessageEmbed().setDescription(`${bot.name} - <@${bot.id}> ([${bot.id}](${KoreanbotsEndPoints.URL.bot(bot.id)}))`)
|
||||
const diffData = objectDiff(
|
||||
{ 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 => {
|
||||
embed.addField(d[0], 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 })
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
interface GetApiRequest extends NextApiRequest {
|
||||
query: {
|
||||
id: string
|
||||
}
|
||||
}
|
||||
|
||||
interface PostApiRequest extends GetApiRequest {
|
||||
body: AddBotSubmit | null
|
||||
}
|
||||
|
||||
interface PatchApiRequest extends GetApiRequest {
|
||||
body: ManageBot | null
|
||||
}
|
||||
|
||||
interface DeleteApiRequest extends GetApiRequest {
|
||||
body: CsrfCaptcha & { name: string } | null
|
||||
}
|
||||
|
||||
export default Bots
|
||||
50
pages/api/v2/bots/[id]/owners.ts
Normal file
50
pages/api/v2/bots/[id]/owners.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import { NextApiRequest } from 'next'
|
||||
|
||||
import RequestHandler from '@utils/RequestHandler'
|
||||
import { CaptchaVerify, get, update } from '@utils/Query'
|
||||
import ResponseWrapper from '@utils/ResponseWrapper'
|
||||
import { checkToken } from '@utils/Csrf'
|
||||
import { checkUserFlag, diff, makeDiscordCodeblock } from '@utils/Tools'
|
||||
import { EditBotOwner, EditBotOwnerSchema } from '@utils/Yup'
|
||||
import { User } from '@types'
|
||||
import { discordLog } from '@utils/DiscordBot'
|
||||
import { MessageEmbed } from 'discord.js'
|
||||
import { KoreanbotsEndPoints } from '@utils/Constants'
|
||||
|
||||
const BotOwners = RequestHandler()
|
||||
.patch(async (req: PostApiRequest, res) => {
|
||||
const user = await get.Authorization(req.cookies.token)
|
||||
if (!user) return ResponseWrapper(res, { code: 401 })
|
||||
const userinfo = await get.user.load(user)
|
||||
const bot = await get.bot.load(req.query.id)
|
||||
if(!bot) return ResponseWrapper(res, { code: 404 })
|
||||
if((bot.owners as User[])[0].id !== user && !checkUserFlag(userinfo.flags, 'staff')) return ResponseWrapper(res, { code: 403 })
|
||||
if(['reported', 'blocked', 'archived'].includes(bot.state) && !checkUserFlag(userinfo.flags, 'staff')) return ResponseWrapper(res, { code: 403, message: '해당 봇은 수정할 수 없습니다.', errors: ['오류라고 생각되면 문의해주세요.'] })
|
||||
const validated = await EditBotOwnerSchema.validate(req.body, { abortEarly: false })
|
||||
.then(el => el)
|
||||
.catch(e => {
|
||||
ResponseWrapper(res, { code: 400, errors: e.errors })
|
||||
return null
|
||||
})
|
||||
if(!validated) return
|
||||
const csrfValidated = checkToken(req, res, validated._csrf)
|
||||
if (!csrfValidated) return
|
||||
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 MessageEmbed().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 {
|
||||
query: {
|
||||
id: string
|
||||
},
|
||||
body: EditBotOwner
|
||||
}
|
||||
|
||||
export default BotOwners
|
||||
53
pages/api/v2/bots/[id]/report.ts
Normal file
53
pages/api/v2/bots/[id]/report.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import { NextApiRequest } from 'next'
|
||||
import rateLimit from 'express-rate-limit'
|
||||
|
||||
import { get } from '@utils/Query'
|
||||
import RequestHandler from '@utils/RequestHandler'
|
||||
import ResponseWrapper from '@utils/ResponseWrapper'
|
||||
import { ReportSchema, Report} from '@utils/Yup'
|
||||
import { getReportChannel } from '@utils/DiscordBot'
|
||||
import { checkToken } from '@utils/Csrf'
|
||||
|
||||
const limiter = rateLimit({
|
||||
windowMs: 5 * 60 * 1000,
|
||||
max: 3,
|
||||
statusCode: 429,
|
||||
skipFailedRequests: true,
|
||||
handler: (_req, res) => ResponseWrapper(res, { code: 429 }),
|
||||
keyGenerator: (req) => req.headers['x-forwarded-for'] as string,
|
||||
skip: (_req, res) => {
|
||||
res.removeHeader('X-RateLimit-Global')
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
const BotReport = RequestHandler().post(limiter)
|
||||
.post(async (req: PostApiRequest, res) => {
|
||||
const user = await get.Authorization(req.cookies.token)
|
||||
if(!user) return ResponseWrapper(res, { code: 401 })
|
||||
const bot = await get.bot.load(req.query.id)
|
||||
if(!bot) return ResponseWrapper(res, { code: 404, message: '존재하지 않는 봇입니다.' })
|
||||
const csrfValidated = checkToken(req, res, req.body._csrf)
|
||||
if (!csrfValidated) return
|
||||
if(!req.body) return ResponseWrapper(res, { code: 400 })
|
||||
const validated: Report = await ReportSchema.validate(req.body, { abortEarly: false })
|
||||
.then(el => el)
|
||||
.catch(e => {
|
||||
ResponseWrapper(res, { code: 400, errors: e.errors })
|
||||
return null
|
||||
})
|
||||
|
||||
if(!validated) return
|
||||
await getReportChannel().send(`Reported by <@${user}> (${user})\nReported **${bot.name}** <@${bot.id}> (${bot.id})\nCategory ${req.body.category}\nDesc\n\`\`\`${req.body.description}\`\`\``, { allowedMentions: { parse: ['users'] }})
|
||||
return ResponseWrapper(res, { code: 200, message: '성공적으로 처리되었습니다.' })
|
||||
})
|
||||
|
||||
|
||||
interface PostApiRequest extends NextApiRequest {
|
||||
body: Report | null
|
||||
query: {
|
||||
id: string
|
||||
}
|
||||
}
|
||||
|
||||
export default BotReport
|
||||
88
pages/api/v2/bots/[id]/stats.ts
Normal file
88
pages/api/v2/bots/[id]/stats.ts
Normal file
@ -0,0 +1,88 @@
|
||||
import { NextApiRequest } from 'next'
|
||||
import rateLimit from 'express-rate-limit'
|
||||
import { MessageEmbed } from 'discord.js'
|
||||
|
||||
import { get, update } from '@utils/Query'
|
||||
import RequestHandler from '@utils/RequestHandler'
|
||||
import ResponseWrapper from '@utils/ResponseWrapper'
|
||||
import { BotStatUpdate, BotStatUpdateSchema } from '@utils/Yup'
|
||||
import { discordLog } from '@utils/DiscordBot'
|
||||
import { checkUserFlag, makeDiscordCodeblock } from '@utils/Tools'
|
||||
import { KoreanbotsEndPoints } from '@utils/Constants'
|
||||
import type { User } from '@types'
|
||||
|
||||
const limiter = rateLimit({
|
||||
windowMs: 3 * 60 * 1000,
|
||||
max: 3,
|
||||
statusCode: 429,
|
||||
skipFailedRequests: true,
|
||||
handler: (_req, res) => ResponseWrapper(res, { code: 429 }),
|
||||
keyGenerator: (req) => req.headers.authorization,
|
||||
skip: (req, res) => {
|
||||
res.removeHeader('X-RateLimit-Global')
|
||||
if(!req.headers.authorization) return true
|
||||
else return false
|
||||
}
|
||||
})
|
||||
|
||||
const patchLimiter = rateLimit({
|
||||
windowMs: 2 * 60 * 1000,
|
||||
max: 6,
|
||||
statusCode: 429,
|
||||
skipFailedRequests: true,
|
||||
handler: (_req, res) => ResponseWrapper(res, { code: 429 }),
|
||||
keyGenerator: (req) => req.headers['x-forwarded-for'] as string,
|
||||
skip: (_req, res) => {
|
||||
res.removeHeader('X-RateLimit-Global')
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
const BotStats = RequestHandler().post(limiter)
|
||||
.post(async (req: PostApiRequest, res) => {
|
||||
const bot = await get.BotAuthorization(req.headers.authorization)
|
||||
if(!bot) return ResponseWrapper(res, { code: 401 })
|
||||
if(!req.body) return ResponseWrapper(res, { code: 400 })
|
||||
const validated: BotStatUpdate = await BotStatUpdateSchema.validate(req.body, { abortEarly: false })
|
||||
.then(el => el)
|
||||
.catch(e => {
|
||||
ResponseWrapper(res, { code: 400, errors: e.errors })
|
||||
return null
|
||||
})
|
||||
|
||||
if(!validated) return
|
||||
const botInfo = await get.bot.load(req.query.id)
|
||||
if(!botInfo) return ResponseWrapper(res, { code: 404, message: '존재하지 않는 봇입니다.' })
|
||||
if(botInfo.id !== bot) return ResponseWrapper(res, { code: 403 })
|
||||
const d = await update.updateServer(botInfo.id, validated.servers)
|
||||
if(d===1 || d===2) return ResponseWrapper(res, { code: 403, message: `서버 수를 ${[null, '1만', '100만'][d]} 이상으로 설정하실 수 없습니다. 문의해주세요.` })
|
||||
get.bot.clear(req.query.id)
|
||||
await discordLog('BOT/STATS', botInfo.id, (new MessageEmbed().setDescription(`${botInfo.name} - <@${botInfo.id}> ([${botInfo.id}](${KoreanbotsEndPoints.URL.bot(botInfo.id)}))`)), null, makeDiscordCodeblock(`${botInfo.servers > validated.servers ? '-' : '+'} ${botInfo.servers} -> ${validated.servers} (${botInfo.servers > validated.servers ? '▼' : '▲'}${Math.abs(validated.servers - botInfo.servers)})`, 'diff'))
|
||||
return ResponseWrapper(res, { code: 200, message: '성공적으로 업데이트 했습니다.'})
|
||||
})
|
||||
.patch(patchLimiter).patch(async (req: ApiRequest, res) => {
|
||||
console.log('1')
|
||||
const user = await get.Authorization(req.cookies.token)
|
||||
if (!user) return ResponseWrapper(res, { code: 401 })
|
||||
const userinfo = await get.user.load(user)
|
||||
const bot = await get.bot.load(req.query.id)
|
||||
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 })
|
||||
get.bot.clear(req.query.id)
|
||||
return ResponseWrapper(res, { code: 200 })
|
||||
})
|
||||
|
||||
|
||||
interface ApiRequest extends NextApiRequest {
|
||||
query: {
|
||||
id: string
|
||||
}
|
||||
}
|
||||
interface PostApiRequest extends ApiRequest {
|
||||
query: {
|
||||
id: string
|
||||
}
|
||||
body: BotStatUpdate
|
||||
}
|
||||
|
||||
export default BotStats
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user