mirror of
https://github.com/koreanbots/core.git
synced 2025-12-16 06:20:24 +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: 버그를 제보해주세요!
|
about: 버그를 제보해주세요!
|
||||||
title: "[버그] "
|
title: "[버그] "
|
||||||
labels: 'bug'
|
labels: bug
|
||||||
assignees: ''
|
assignees: ''
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 재현방법
|
## 재현방법
|
||||||
|
|
||||||
> 어떻게하면 발생시킬 수 있나요?
|
> 어떻게하면 발생시킬 수 있나요?
|
||||||
|
|
||||||
## 예상되는 정상적인 동작
|
## 예상되는 정상적인 동작
|
||||||
|
|
||||||
> 정상이라면 어떻게 되야하나요?
|
> 정상이라면 어떻게 되야하나요?
|
||||||
|
|
||||||
## 발생한 문제
|
## 발생한 문제
|
||||||
|
|
||||||
> 어떤 문제가 발생하나요?
|
> 어떤 문제가 발생하나요?
|
||||||
|
|
||||||
## 클라이언트 버전
|
## 클라이언트 버전
|
||||||
|
|
||||||
> 클라이언트 버전을 알려주세요!
|
> 클라이언트 버전을 알려주세요!
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
클라이언트 버전을 가져오실 줄 모르신다면 아래 링크를 참고해주세요
|
클라이언트 버전을 가져오실 줄 모르신다면 아래 링크를 참고해주세요
|
||||||
|
|
||||||
https://github.com/koreanbots/docs/blob/master/version.md
|
https://github.com/koreanbots/docs/blob/master/version.md
|
||||||
-->
|
-->
|
||||||
|
|
||||||
## 사양
|
## 사양
|
||||||
|
|
||||||
> OS 정보와 브라우저의 버전을 알려주세요!
|
> 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
|
[기여 규칙](./.github/CONTRIBUTING.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)
|
|
||||||
|
|
||||||
**이슈에서는 빠른 소통을 위해 약자를 사용합니다.**
|
<!-- 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** 의 약자로 재현 가능한 버그라는 뜻입니다.
|
<!-- markdownlint-restore -->
|
||||||
- `CNR` **Can Not Reproduce** 의 약자로 재현이 불가능하다는 뜻입니다.
|
<!-- prettier-ignore-end -->
|
||||||
- `NAB` **Not a Bug** 의 약자로 버그에 해당하지 않는다는 뜻입니다.
|
|
||||||
|
|
||||||
#### 승인과 거부
|
<!-- ALL-CONTRIBUTORS-LIST: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/)
|
|
||||||
|
|||||||
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;
|
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
|
-- USING NULL
|
||||||
UPDATE `bots` SET web=NULL where web='false';
|
UPDATE `bots` SET web=NULL where web='false' or web='';
|
||||||
UPDATE `bots` SET git=NULL where git='false';
|
UPDATE `bots` SET git=NULL where git='false' or git='';
|
||||||
UPDATE `bots` SET url=NULL where url='false';
|
UPDATE `bots` SET `url`=NULL where `url`='false' or `url`='';
|
||||||
UPDATE `bots` SET avatar=NULL where avatar='false';
|
UPDATE `bots` SET avatar=NULL where avatar='false' or avatar='';
|
||||||
UPDATE `bots` SET discord=NULL where discord='false';
|
UPDATE `bots` SET discord=NULL where discord='false' or discord='';
|
||||||
UPDATE `bots` SET vanity=NULL where vanity='false';
|
UPDATE `bots` SET vanity=NULL where vanity='false' or vanity='';
|
||||||
UPDATE `bots` SET bg=NULL where bg='false';
|
UPDATE `bots` SET bg=NULL where bg='false' or bg='';
|
||||||
UPDATE `bots` SET banner=NULL where banner='false';
|
UPDATE `bots` SET banner=NULL where banner='false' or banner='';
|
||||||
ALTER TABLE `bots` ADD COLUMN partnered BOOLEAN NOT NULL DEFAULT 0;
|
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
|
-- users TABLE
|
||||||
UPDATE `users` SET perm=0;
|
ALTER TABLE `users` ADD `flags` INT NOT NULL DEFAULT '0';
|
||||||
ALTER TABLE `users` CHANGE `perm` `perm` INT(5) NOT NULL DEFAULT '0';
|
|
||||||
ALTER TABLE `users` ADD `email` TEXT NULL AFTER `id`;
|
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`;
|
ALTER TABLE `users` ADD `discord` TEXT NOT NULL AFTER `token`;
|
||||||
UPDATE `users` SET `stared` = `votes` WHERE `id`=`id`;
|
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="{}";
|
UPDATE `users` SET votes="{}";
|
||||||
|
|
||||||
-- submitted TABLE
|
-- 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 `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;
|
ALTER TABLE `submitted` DROP `name`;
|
||||||
|
ALTER TABLE `submitted` DROP `tag`;
|
||||||
-- submits TABLE
|
ALTER TABLE `submitted` DROP `votes`;
|
||||||
|
ALTER TABLE `submitted` DROP `servers`;
|
||||||
CREATE TABLE `submits` (
|
ALTER TABLE `submitted` DROP `status`;
|
||||||
`id` mediumtext COLLATE utf8mb4_unicode_ci NOT NULL,
|
ALTER TABLE `submitted` DROP `verified`;
|
||||||
`date` int(11) NOT NULL,
|
ALTER TABLE `submitted` DROP `trusted`;
|
||||||
`owners` mediumtext COLLATE utf8mb4_unicode_ci NOT NULL,
|
ALTER TABLE `submitted` DROP `avatar`;
|
||||||
`lib` mediumtext COLLATE utf8mb4_unicode_ci NOT NULL,
|
ALTER TABLE `submitted` ADD `reason` TINYTEXT NULL DEFAULT NULL;
|
||||||
`prefix` mediumtext COLLATE utf8mb4_unicode_ci NOT NULL,
|
UPDATE `submitted` SET web=NULL where web='false' or web='';
|
||||||
`intro` mediumtext COLLATE utf8mb4_unicode_ci NOT NULL,
|
UPDATE `submitted` SET git=NULL where git='false' or git='';
|
||||||
`desc` longtext COLLATE utf8mb4_unicode_ci NOT NULL,
|
UPDATE `submitted` SET `url`=NULL where `url`='false' or `url`='';
|
||||||
`web` mediumtext COLLATE utf8mb4_unicode_ci DEFAULT NULL,
|
UPDATE `submitted` SET discord=NULL where discord='false' or discord='';
|
||||||
`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;
|
|
||||||
|
|
||||||
-- reports TABLE
|
-- 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