Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52f6fefed0 | ||
|
|
840555df42 | ||
|
|
9aa867e8aa | ||
|
|
d70b39f445 | ||
|
|
33055ecbf9 | ||
|
|
2e39fbce77 | ||
|
|
1b3b020d52 | ||
|
|
33d805ee82 | ||
|
|
57c02dd51c | ||
|
|
5b6bb98860 | ||
|
|
c01112a6c1 | ||
|
|
064b0967fc | ||
|
|
9f4c59abec | ||
|
|
d8547a0a3d | ||
|
|
9c51bd602b | ||
|
|
bc3f504ca3 | ||
|
|
761cbb7828 | ||
|
|
01fe3eb4cb | ||
|
|
f03dd8122b | ||
|
|
cfda2a6bf8 | ||
|
|
74ce668221 | ||
|
|
5a18d24464 | ||
|
|
5125862f80 | ||
|
|
fa791857ec | ||
|
|
24ad29ea99 | ||
|
|
cdfb972bd0 | ||
|
|
1985ce6ff8 | ||
|
|
d84eb65d55 | ||
|
|
16474fb5d0 | ||
|
|
186138b884 | ||
|
|
9ca6e37e39 |
|
|
@ -9,9 +9,10 @@ services:
|
||||||
# Update 'VARIANT' to pick a version of PHP version: 8, 8.1, 8.0, 7, 7.4
|
# Update 'VARIANT' to pick a version of PHP version: 8, 8.1, 8.0, 7, 7.4
|
||||||
# Append -bullseye or -buster to pin to an OS version.
|
# Append -bullseye or -buster to pin to an OS version.
|
||||||
# Use -bullseye variants on local arm64/Apple Silicon.
|
# Use -bullseye variants on local arm64/Apple Silicon.
|
||||||
VARIANT: 8-bullseye
|
VARIANT: "8-bullseye"
|
||||||
# Optional Node.js version
|
# Optional Node.js version
|
||||||
NODE_VERSION: 'lts/*'
|
NODE_VERSION: "lts/*"
|
||||||
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
- ..:/workspace:cached
|
- ..:/workspace:cached
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,10 @@ root = true
|
||||||
[*]
|
[*]
|
||||||
charset = utf-8
|
charset = utf-8
|
||||||
end_of_line = lf
|
end_of_line = lf
|
||||||
indent_size = 4
|
indent_size = 2
|
||||||
indent_style = tab
|
indent_style = space
|
||||||
insert_final_newline = true
|
insert_final_newline = true
|
||||||
trim_trailing_whitespace = true
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
[*.{json,yaml,yml}]
|
[*.{php,md,ps1,Dockerfile}]
|
||||||
indent_style = space
|
indent_size = 4
|
||||||
indent_size = 2
|
|
||||||
|
|
|
||||||
9
.eslintignore
Normal file
9
.eslintignore
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
public/
|
||||||
|
vendor/
|
||||||
|
coverage/
|
||||||
|
plugins/
|
||||||
|
node_modules/
|
||||||
|
*.d.ts
|
||||||
|
resources/assets/tests/__mocks__/
|
||||||
|
resources/assets/tests/ts-shims/
|
||||||
|
resources/assets/tests/*.ts
|
||||||
27
.eslintrc.yml
Normal file
27
.eslintrc.yml
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
root: true
|
||||||
|
parser: '@typescript-eslint/parser'
|
||||||
|
parserOptions:
|
||||||
|
project: tsconfig.eslint.json
|
||||||
|
plugins:
|
||||||
|
- '@typescript-eslint/eslint-plugin'
|
||||||
|
extends:
|
||||||
|
- eslint:recommended
|
||||||
|
- plugin:@typescript-eslint/recommended
|
||||||
|
- plugin:@typescript-eslint/recommended-requiring-type-checking
|
||||||
|
- plugin:react-hooks/recommended
|
||||||
|
rules:
|
||||||
|
prefer-const: error
|
||||||
|
'@typescript-eslint/no-unsafe-assignment': off
|
||||||
|
'@typescript-eslint/no-unsafe-member-access': off
|
||||||
|
'@typescript-eslint/no-unsafe-return': off
|
||||||
|
'@typescript-eslint/no-unused-vars': off
|
||||||
|
'@typescript-eslint/explicit-module-boundary-types': off
|
||||||
|
'@typescript-eslint/no-explicit-any': off
|
||||||
|
'@typescript-eslint/ban-ts-comment': off
|
||||||
|
'@typescript-eslint/no-non-null-assertion': off
|
||||||
|
'@typescript-eslint/no-floating-promises': off
|
||||||
|
'@typescript-eslint/no-misused-promises':
|
||||||
|
- off
|
||||||
|
- checksVoidReturn: false
|
||||||
|
'@typescript-eslint/unbound-method': off
|
||||||
|
'@typescript-eslint/restrict-template-expressions': off
|
||||||
15
.github/workflows/CI.yml
vendored
15
.github/workflows/CI.yml
vendored
|
|
@ -26,14 +26,13 @@ jobs:
|
||||||
with:
|
with:
|
||||||
php-version: 8.3
|
php-version: 8.3
|
||||||
coverage: none
|
coverage: none
|
||||||
extensions: mbstring, dom, fileinfo, gd
|
extensions: mbstring, dom, fileinfo, gd, imagick
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
composer install --prefer-dist --no-progress
|
composer install --prefer-dist --no-progress
|
||||||
- name: Prepare
|
- name: Prepare
|
||||||
run: |
|
run: |
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
php artisan key:generate
|
|
||||||
mkdir -p resources/views/overrides
|
mkdir -p resources/views/overrides
|
||||||
- name: Validate Twig templates
|
- name: Validate Twig templates
|
||||||
run: php artisan twig:lint -v
|
run: php artisan twig:lint -v
|
||||||
|
|
@ -55,14 +54,14 @@ jobs:
|
||||||
with:
|
with:
|
||||||
php-version: ${{ matrix.php }}
|
php-version: ${{ matrix.php }}
|
||||||
coverage: none
|
coverage: none
|
||||||
extensions: mbstring, dom, fileinfo, sqlite, gd, zip
|
extensions: mbstring, dom, fileinfo, sqlite, gd, zip, imagick
|
||||||
- name: Setup PHP with Xdebug
|
- name: Setup PHP with Xdebug
|
||||||
uses: shivammathur/setup-php@v2
|
uses: shivammathur/setup-php@v2
|
||||||
if: matrix.php == '8.3'
|
if: matrix.php == '8.3'
|
||||||
with:
|
with:
|
||||||
php-version: ${{ matrix.php }}
|
php-version: ${{ matrix.php }}
|
||||||
coverage: xdebug
|
coverage: xdebug
|
||||||
extensions: mbstring, dom, fileinfo, sqlite, gd, zip
|
extensions: mbstring, dom, fileinfo, sqlite, gd, zip, imagick
|
||||||
- name: Cache Composer dependencies
|
- name: Cache Composer dependencies
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v3
|
||||||
with:
|
with:
|
||||||
|
|
@ -120,7 +119,7 @@ jobs:
|
||||||
with:
|
with:
|
||||||
php-version: 8.2
|
php-version: 8.2
|
||||||
coverage: none
|
coverage: none
|
||||||
extensions: mbstring, dom, fileinfo, sqlite, gd, zip
|
extensions: mbstring, dom, fileinfo, sqlite, gd, zip, imagick
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
- name: Cache Node dependencies
|
- name: Cache Node dependencies
|
||||||
|
|
@ -144,11 +143,13 @@ jobs:
|
||||||
yarn build
|
yarn build
|
||||||
cp resources/assets/src/images/bg.webp public/app/
|
cp resources/assets/src/images/bg.webp public/app/
|
||||||
cp resources/assets/src/images/favicon.ico public/app/
|
cp resources/assets/src/images/favicon.ico public/app/
|
||||||
- uses: benjlevesque/short-sha@v1.2
|
- uses: benjlevesque/short-sha@v3.0
|
||||||
id: short-sha
|
id: short-sha
|
||||||
- name: Archive release
|
- name: Archive release
|
||||||
run: zip -9 -r blessing-skin-server-${{ steps.short-sha.outputs.sha }}.zip app bootstrap config database plugins public resources/lang resources/views resources/misc/textures routes storage vendor .env.example artisan LICENSE README.md README-zh.md index.html
|
run: zip -9 -r blessing-skin-server-${{ steps.short-sha.outputs.sha }}.zip app bootstrap config database plugins public resources/lang resources/views resources/misc/textures routes storage vendor .env.example artisan LICENSE README.md README-zh.md index.html
|
||||||
- name: Upload artifact
|
- name: Upload artifact
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
|
if-no-files-found: error
|
||||||
|
name: blessing-skin-server-${{ steps.short-sha.outputs.sha }}.zip
|
||||||
path: blessing-skin-server-${{ steps.short-sha.outputs.sha }}.zip
|
path: blessing-skin-server-${{ steps.short-sha.outputs.sha }}.zip
|
||||||
|
|
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -25,3 +25,5 @@ storage/options.php
|
||||||
.phpunit.result.cache
|
.phpunit.result.cache
|
||||||
.php-cs-fixer.cache
|
.php-cs-fixer.cache
|
||||||
resources/views/overrides
|
resources/views/overrides
|
||||||
|
.DS_Store
|
||||||
|
*/.DS_Store
|
||||||
27
.gitpod.yml
27
.gitpod.yml
|
|
@ -13,15 +13,28 @@ tasks:
|
||||||
php artisan serve --host=0.0.0.0
|
php artisan serve --host=0.0.0.0
|
||||||
- command: gp ports await 8080 && gp preview $(gp url 8000)
|
- command: gp ports await 8080 && gp preview $(gp url 8000)
|
||||||
|
|
||||||
|
github:
|
||||||
|
prebuilds:
|
||||||
|
# enable for the master/default branch (defaults to true)
|
||||||
|
master: true
|
||||||
|
# enable for all branches in this repo (defaults to false)
|
||||||
|
branches: false
|
||||||
|
# enable for pull requests coming from this repo (defaults to true)
|
||||||
|
pullRequests: true
|
||||||
|
# add a check to pull requests (defaults to true)
|
||||||
|
addCheck: true
|
||||||
|
# add a "Review in Gitpod" button as a comment to pull requests (defaults to false)
|
||||||
|
addComment: false
|
||||||
|
|
||||||
vscode:
|
vscode:
|
||||||
extensions:
|
extensions:
|
||||||
- editorconfig.editorconfig
|
- 'editorconfig.editorconfig'
|
||||||
- eamodio.gitlens
|
- 'eamodio.gitlens'
|
||||||
- bmewburn.vscode-intelephense-client
|
- 'bmewburn.vscode-intelephense-client'
|
||||||
- esbenp.prettier-vscode
|
- 'esbenp.prettier-vscode'
|
||||||
- jpoissonnier.vscode-styled-components
|
- 'jpoissonnier.vscode-styled-components'
|
||||||
- mblode.twig-language-2
|
- 'mblode.twig-language-2'
|
||||||
- felixfbecker.php-debug
|
- 'felixfbecker.php-debug'
|
||||||
|
|
||||||
ports:
|
ports:
|
||||||
- port: 8080
|
- port: 8080
|
||||||
|
|
|
||||||
4
.husky/pre-commit
Executable file
4
.husky/pre-commit
Executable file
|
|
@ -0,0 +1,4 @@
|
||||||
|
#!/bin/sh
|
||||||
|
. "$(dirname "$0")/_/husky.sh"
|
||||||
|
|
||||||
|
yarn pretty-quick --staged
|
||||||
8
.vscode/launch.json
vendored
8
.vscode/launch.json
vendored
|
|
@ -8,17 +8,13 @@
|
||||||
"program": "${workspaceFolder}/node_modules/.bin/jest",
|
"program": "${workspaceFolder}/node_modules/.bin/jest",
|
||||||
"args": ["${file}"],
|
"args": ["${file}"],
|
||||||
"internalConsoleOptions": "openOnSessionStart",
|
"internalConsoleOptions": "openOnSessionStart",
|
||||||
"skipFiles": [
|
"skipFiles": ["<node_internals>/**"]
|
||||||
"<node_internals>/**"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "php",
|
"type": "php",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"name": "Launch with XDebug",
|
"name": "Launch with XDebug",
|
||||||
"ignore": [
|
"ignore": ["**/vendor/**/*.php"]
|
||||||
"**/vendor/**/*.php"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "firefox",
|
"type": "firefox",
|
||||||
|
|
|
||||||
101
README-zh.md
101
README-zh.md
|
|
@ -52,6 +52,7 @@ Blessing Skin 对您的服务器有一定的要求。在大多数情况下,下
|
||||||
- JSON
|
- JSON
|
||||||
- fileinfo
|
- fileinfo
|
||||||
- zip
|
- zip
|
||||||
|
- Imagick
|
||||||
|
|
||||||
## 快速使用
|
## 快速使用
|
||||||
|
|
||||||
|
|
@ -61,105 +62,9 @@ Blessing Skin 对您的服务器有一定的要求。在大多数情况下,下
|
||||||
|
|
||||||
Blessing Skin 提供了强大的插件系统,您可以通过添加多种多样的插件来为您的皮肤站添加功能。
|
Blessing Skin 提供了强大的插件系统,您可以通过添加多种多样的插件来为您的皮肤站添加功能。
|
||||||
|
|
||||||
## 支持并赞助 Blessing Skin
|
|
||||||
|
|
||||||
如果您觉得这个软件对您很有帮助,欢迎通过赞助来支持开发!
|
|
||||||
|
|
||||||
目前可在 [爱发电](https://afdian.net/@blessing-skin) 上赞助。
|
|
||||||
|
|
||||||
### Sponsors
|
|
||||||
|
|
||||||
<table>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td align=center>
|
|
||||||
<a href="https://afdian.net/@gao_cai_sheng">
|
|
||||||
<img src="https://pic1.afdiancdn.com/user/2aac23481b1b11ea9f6e52540025c377/avatar/96a8b23d98cbac5aa36601db15a27e5e_w512_h512_s234.jpg" width="120" height="120">
|
|
||||||
<br>
|
|
||||||
gao_cai_sheng
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
<td align=center>
|
|
||||||
<a href="https://afdian.net/@LD_fantasy">
|
|
||||||
<img src="https://pic1.afdiancdn.com/user/9bed7bb454f011eb821652540025c377/avatar/cb679e3eac693e0eea2eac527c7954e0_w700_h1307_s137.jpg" width="120" height="120">
|
|
||||||
<br>
|
|
||||||
K_LazyCat
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
<td align=center>
|
|
||||||
<a href="https://afdian.net/@nmzy2018">
|
|
||||||
<img src="https://pic1.afdiancdn.com/user/a66f79d2f5a311e9af4e52540025c377/avatar/98682fb3c5914a39c8986bb1e97b5501_w512_h512_s248.jpg" width="120" height="120">
|
|
||||||
<br>
|
|
||||||
伊南
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
<td align=center>
|
|
||||||
<a href="">
|
|
||||||
<img src="https://pic1.afdiancdn.com/default/avatar/avatar-blue.png" width="120" height="120">
|
|
||||||
<br>
|
|
||||||
家乐
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
<td align=center>
|
|
||||||
<a href="https://afdian.net/@oar-01">
|
|
||||||
<img src="https://pic1.afdiancdn.com/user/e391f6ccdfa911ebb0e352540025c377/avatar/74da4afa92fa2666c306d43ab7a8804b_w1920_h1080_s338.jpg" width="120" height="120">
|
|
||||||
<br>
|
|
||||||
黄金鞘翅的郡主
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td align=center>
|
|
||||||
<a href="https://www.bilibili.plus/caucmc1.orz">
|
|
||||||
<img src="https://pic1.afdiancdn.com/user/edde2efc879611e889f552540025c377/avatar/d6a712efd6560b28989ac33f99c8915d_w473_h454_s24.jpg" width="120" height="120">
|
|
||||||
<br>
|
|
||||||
睡觉塞牙
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
### Backers
|
|
||||||
|
|
||||||
<table>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td align=center>
|
|
||||||
<a href="https://afdian.net/@ValiantShishu976400">
|
|
||||||
<img src="https://pic1.afdiancdn.com/user/178a08963a5e11e9addd52540025c377/avatar/ece9f089aaf2c2f83204a8de11697caf_w350_h350_s16.jpg" width="75" height="75">
|
|
||||||
<br>
|
|
||||||
飒爽师叔
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
<td align=center>
|
|
||||||
<a href="https://afdian.net/@PAKingdom">
|
|
||||||
<img src="https://pic1.afdiancdn.com/user/18ad3338e58a11e9b29352540025c377/avatar/1e8b6476b589ddac545ac1ce13166e59_w584_h797_s59.jpg" width="75" height="75">
|
|
||||||
<br>
|
|
||||||
皮皮帕
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
<td align=center>
|
|
||||||
<a href="https://afdian.net/@oar-01">
|
|
||||||
<img src="https://pic1.afdiancdn.com/user/e391f6ccdfa911ebb0e352540025c377/avatar/74da4afa92fa2666c306d43ab7a8804b_w1920_h1080_s338.jpg" width="75" height="75">
|
|
||||||
<br>
|
|
||||||
黄金鞘翅的郡主
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
<td align=center>
|
|
||||||
<a href="">
|
|
||||||
<img src="https://pic1.afdiancdn.com/user/fc143860efa111ebb3e552540025c377/avatar/6e1d0f3f6ffb80b89b44269f59aa775f_w1080_h1080_s107.jpg" width="75" height="75">
|
|
||||||
<br>
|
|
||||||
♂sudo rm -rf /*[幼稚鬼]
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
## 自行构建
|
## 自行构建
|
||||||
|
|
||||||
详情可阅读 [这里](https://blessing.netlify.com/build.html)。
|
详情可阅读 [这里](https://blessing.netlify.app/build.html)。
|
||||||
|
|
||||||
> 您可以订阅我们的 Telegram 频道 [Blessing Skin News](https://t.me/blessing_skin_news) 来获取最新开发动态。当有新的 Commit 被推送时,我们的机器人将会在频道内发送一条消息来提示您能否拉取最新代码,以及拉取后应该做什么。
|
> 您可以订阅我们的 Telegram 频道 [Blessing Skin News](https://t.me/blessing_skin_news) 来获取最新开发动态。当有新的 Commit 被推送时,我们的机器人将会在频道内发送一条消息来提示您能否拉取最新代码,以及拉取后应该做什么。
|
||||||
|
|
||||||
|
|
@ -171,7 +76,7 @@ Blessing Skin 可支持多种语言,当前支持英语、简体中文和西班
|
||||||
|
|
||||||
## 问题报告
|
## 问题报告
|
||||||
|
|
||||||
请参阅 [报告问题的正确姿势](https://blessing.netlify.com/report.html)。
|
请参阅 [报告问题的正确姿势](https://blessing.netlify.app/report.html)。
|
||||||
|
|
||||||
## 相关链接
|
## 相关链接
|
||||||
|
|
||||||
|
|
|
||||||
97
README.md
97
README.md
|
|
@ -52,6 +52,7 @@ Blessing Skin has only a few system requirements. In most cases, these PHP exten
|
||||||
- JSON
|
- JSON
|
||||||
- fileinfo
|
- fileinfo
|
||||||
- zip
|
- zip
|
||||||
|
- Imagick
|
||||||
|
|
||||||
## Quick Install
|
## Quick Install
|
||||||
|
|
||||||
|
|
@ -61,102 +62,6 @@ Please read [Installation Guide](https://blessing.netlify.app/en/setup.html).
|
||||||
|
|
||||||
Blessing Skin provides an elegant and powerful plugin system, and you can attach plenty of functions and customization to your site via installing plugins.
|
Blessing Skin provides an elegant and powerful plugin system, and you can attach plenty of functions and customization to your site via installing plugins.
|
||||||
|
|
||||||
## Supporting Blessing Skin
|
|
||||||
|
|
||||||
Welcome to sponsoring Blessing Skin if this software is useful for you!
|
|
||||||
|
|
||||||
Currently you can sponsor us via [爱发电](https://afdian.net/@blessing-skin).
|
|
||||||
|
|
||||||
### Sponsors
|
|
||||||
|
|
||||||
<table>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td align=center>
|
|
||||||
<a href="https://afdian.net/@gao_cai_sheng">
|
|
||||||
<img src="https://pic1.afdiancdn.com/user/2aac23481b1b11ea9f6e52540025c377/avatar/96a8b23d98cbac5aa36601db15a27e5e_w512_h512_s234.jpg" width="120" height="120">
|
|
||||||
<br>
|
|
||||||
gao_cai_sheng
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
<td align=center>
|
|
||||||
<a href="https://afdian.net/@LD_fantasy">
|
|
||||||
<img src="https://pic1.afdiancdn.com/user/9bed7bb454f011eb821652540025c377/avatar/cb679e3eac693e0eea2eac527c7954e0_w700_h1307_s137.jpg" width="120" height="120">
|
|
||||||
<br>
|
|
||||||
K_LazyCat
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
<td align=center>
|
|
||||||
<a href="https://afdian.net/@nmzy2018">
|
|
||||||
<img src="https://pic1.afdiancdn.com/user/a66f79d2f5a311e9af4e52540025c377/avatar/98682fb3c5914a39c8986bb1e97b5501_w512_h512_s248.jpg" width="120" height="120">
|
|
||||||
<br>
|
|
||||||
伊南
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
<td align=center>
|
|
||||||
<a href="">
|
|
||||||
<img src="https://pic1.afdiancdn.com/default/avatar/avatar-blue.png" width="120" height="120">
|
|
||||||
<br>
|
|
||||||
家乐
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
<td align=center>
|
|
||||||
<a href="https://afdian.net/@oar-01">
|
|
||||||
<img src="https://pic1.afdiancdn.com/user/e391f6ccdfa911ebb0e352540025c377/avatar/74da4afa92fa2666c306d43ab7a8804b_w1920_h1080_s338.jpg" width="120" height="120">
|
|
||||||
<br>
|
|
||||||
黄金鞘翅的郡主
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td align=center>
|
|
||||||
<a href="https://www.bilibili.plus/caucmc1.orz">
|
|
||||||
<img src="https://pic1.afdiancdn.com/user/edde2efc879611e889f552540025c377/avatar/d6a712efd6560b28989ac33f99c8915d_w473_h454_s24.jpg" width="120" height="120">
|
|
||||||
<br>
|
|
||||||
睡觉塞牙
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
### Backers
|
|
||||||
|
|
||||||
<table>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td align=center>
|
|
||||||
<a href="https://afdian.net/@ValiantShishu976400">
|
|
||||||
<img src="https://pic1.afdiancdn.com/user/178a08963a5e11e9addd52540025c377/avatar/ece9f089aaf2c2f83204a8de11697caf_w350_h350_s16.jpg" width="75" height="75">
|
|
||||||
<br>
|
|
||||||
飒爽师叔
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
<td align=center>
|
|
||||||
<a href="https://afdian.net/@PAKingdom">
|
|
||||||
<img src="https://pic1.afdiancdn.com/user/18ad3338e58a11e9b29352540025c377/avatar/1e8b6476b589ddac545ac1ce13166e59_w584_h797_s59.jpg" width="75" height="75">
|
|
||||||
<br>
|
|
||||||
皮皮帕
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
<td align=center>
|
|
||||||
<a href="https://afdian.net/@oar-01">
|
|
||||||
<img src="https://pic1.afdiancdn.com/user/e391f6ccdfa911ebb0e352540025c377/avatar/74da4afa92fa2666c306d43ab7a8804b_w1920_h1080_s338.jpg" width="75" height="75">
|
|
||||||
<br>
|
|
||||||
黄金鞘翅的郡主
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
<td align=center>
|
|
||||||
<a href="">
|
|
||||||
<img src="https://pic1.afdiancdn.com/user/fc143860efa111ebb3e552540025c377/avatar/6e1d0f3f6ffb80b89b44269f59aa775f_w1080_h1080_s107.jpg" width="75" height="75">
|
|
||||||
<br>
|
|
||||||
♂sudo rm -rf /*[幼稚鬼]
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
## Build From Source
|
## Build From Source
|
||||||
|
|
||||||
Please refer to [Manual Build](https://blessing.netlify.app/build.html).
|
Please refer to [Manual Build](https://blessing.netlify.app/build.html).
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,7 @@ class AdminController extends Controller
|
||||||
Request $request,
|
Request $request,
|
||||||
PluginManager $plugins,
|
PluginManager $plugins,
|
||||||
Filesystem $filesystem,
|
Filesystem $filesystem,
|
||||||
Filter $filter
|
Filter $filter,
|
||||||
) {
|
) {
|
||||||
$db = config('database.connections.'.config('database.default'));
|
$db = config('database.connections.'.config('database.default'));
|
||||||
$dbType = Arr::get([
|
$dbType = Arr::get([
|
||||||
|
|
|
||||||
|
|
@ -8,16 +8,16 @@ use App\Mail\ForgotPassword;
|
||||||
use App\Models\Player;
|
use App\Models\Player;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Rules;
|
use App\Rules;
|
||||||
use Auth;
|
|
||||||
use Blessing\Filter;
|
use Blessing\Filter;
|
||||||
use Blessing\Rejection;
|
use Blessing\Rejection;
|
||||||
use Cache;
|
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
use Illuminate\Contracts\Events\Dispatcher;
|
use Illuminate\Contracts\Events\Dispatcher;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Mail;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Session;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use URL;
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
use Illuminate\Support\Facades\Session;
|
||||||
|
use Illuminate\Support\Facades\URL;
|
||||||
use Vectorface\Whip\Whip;
|
use Vectorface\Whip\Whip;
|
||||||
|
|
||||||
class AuthController extends Controller
|
class AuthController extends Controller
|
||||||
|
|
@ -50,7 +50,7 @@ class AuthController extends Controller
|
||||||
Request $request,
|
Request $request,
|
||||||
Rules\Captcha $captcha,
|
Rules\Captcha $captcha,
|
||||||
Dispatcher $dispatcher,
|
Dispatcher $dispatcher,
|
||||||
Filter $filter
|
Filter $filter,
|
||||||
) {
|
) {
|
||||||
$data = $request->validate([
|
$data = $request->validate([
|
||||||
'identification' => 'required',
|
'identification' => 'required',
|
||||||
|
|
@ -151,7 +151,7 @@ class AuthController extends Controller
|
||||||
Request $request,
|
Request $request,
|
||||||
Rules\Captcha $captcha,
|
Rules\Captcha $captcha,
|
||||||
Dispatcher $dispatcher,
|
Dispatcher $dispatcher,
|
||||||
Filter $filter
|
Filter $filter,
|
||||||
) {
|
) {
|
||||||
$can = $filter->apply('can_register', null);
|
$can = $filter->apply('can_register', null);
|
||||||
if ($can instanceof Rejection) {
|
if ($can instanceof Rejection) {
|
||||||
|
|
@ -248,7 +248,7 @@ class AuthController extends Controller
|
||||||
Request $request,
|
Request $request,
|
||||||
Rules\Captcha $captcha,
|
Rules\Captcha $captcha,
|
||||||
Dispatcher $dispatcher,
|
Dispatcher $dispatcher,
|
||||||
Filter $filter
|
Filter $filter,
|
||||||
) {
|
) {
|
||||||
$data = $request->validate([
|
$data = $request->validate([
|
||||||
'email' => 'required|email',
|
'email' => 'required|email',
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,12 @@ namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Models\Texture;
|
use App\Models\Texture;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Auth;
|
|
||||||
use Blessing\Filter;
|
use Blessing\Filter;
|
||||||
use Blessing\Rejection;
|
use Blessing\Rejection;
|
||||||
use Illuminate\Contracts\Events\Dispatcher;
|
use Illuminate\Contracts\Events\Dispatcher;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
||||||
class ClosetController extends Controller
|
class ClosetController extends Controller
|
||||||
{
|
{
|
||||||
|
|
@ -75,7 +75,7 @@ class ClosetController extends Controller
|
||||||
public function add(
|
public function add(
|
||||||
Request $request,
|
Request $request,
|
||||||
Dispatcher $dispatcher,
|
Dispatcher $dispatcher,
|
||||||
Filter $filter
|
Filter $filter,
|
||||||
) {
|
) {
|
||||||
['tid' => $tid, 'name' => $name] = $request->validate([
|
['tid' => $tid, 'name' => $name] = $request->validate([
|
||||||
'tid' => 'required|integer',
|
'tid' => 'required|integer',
|
||||||
|
|
@ -132,7 +132,7 @@ class ClosetController extends Controller
|
||||||
Request $request,
|
Request $request,
|
||||||
Dispatcher $dispatcher,
|
Dispatcher $dispatcher,
|
||||||
Filter $filter,
|
Filter $filter,
|
||||||
$tid
|
$tid,
|
||||||
) {
|
) {
|
||||||
['name' => $name] = $request->validate(['name' => 'required']);
|
['name' => $name] = $request->validate(['name' => 'required']);
|
||||||
/** @var User */
|
/** @var User */
|
||||||
|
|
|
||||||
|
|
@ -163,6 +163,10 @@ class OptionsController extends Controller
|
||||||
->text('max_upload_file_size')->addon('KB')
|
->text('max_upload_file_size')->addon('KB')
|
||||||
->hint(trans('options.general.max_upload_file_size.hint', ['size' => ini_get('upload_max_filesize')]));
|
->hint(trans('options.general.max_upload_file_size.hint', ['size' => ini_get('upload_max_filesize')]));
|
||||||
|
|
||||||
|
$form->group('max_texture_width')
|
||||||
|
->text('max_texture_width')->addon('px')
|
||||||
|
->hint(trans('options.general.max_texture_width.hint'));
|
||||||
|
|
||||||
$form->select('player_name_rule')
|
$form->select('player_name_rule')
|
||||||
->option('official', trans('options.general.player_name_rule.official'))
|
->option('official', trans('options.general.player_name_rule.official'))
|
||||||
->option('cjk', trans('options.general.player_name_rule.cjk'))
|
->option('cjk', trans('options.general.player_name_rule.cjk'))
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,11 @@ use App\Models\Player;
|
||||||
use App\Models\Texture;
|
use App\Models\Texture;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Rules;
|
use App\Rules;
|
||||||
use Auth;
|
|
||||||
use Blessing\Filter;
|
use Blessing\Filter;
|
||||||
use Blessing\Rejection;
|
use Blessing\Rejection;
|
||||||
use Illuminate\Contracts\Events\Dispatcher;
|
use Illuminate\Contracts\Events\Dispatcher;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
class PlayerController extends Controller
|
class PlayerController extends Controller
|
||||||
|
|
@ -124,7 +124,7 @@ class PlayerController extends Controller
|
||||||
public function delete(
|
public function delete(
|
||||||
Dispatcher $dispatcher,
|
Dispatcher $dispatcher,
|
||||||
Filter $filter,
|
Filter $filter,
|
||||||
Player $player
|
Player $player,
|
||||||
) {
|
) {
|
||||||
/** @var User */
|
/** @var User */
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
@ -157,7 +157,7 @@ class PlayerController extends Controller
|
||||||
Request $request,
|
Request $request,
|
||||||
Dispatcher $dispatcher,
|
Dispatcher $dispatcher,
|
||||||
Filter $filter,
|
Filter $filter,
|
||||||
Player $player
|
Player $player,
|
||||||
) {
|
) {
|
||||||
$name = $request->validate([
|
$name = $request->validate([
|
||||||
'name' => [
|
'name' => [
|
||||||
|
|
@ -194,7 +194,7 @@ class PlayerController extends Controller
|
||||||
Request $request,
|
Request $request,
|
||||||
Dispatcher $dispatcher,
|
Dispatcher $dispatcher,
|
||||||
Filter $filter,
|
Filter $filter,
|
||||||
Player $player
|
Player $player,
|
||||||
) {
|
) {
|
||||||
/** @var User */
|
/** @var User */
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
@ -234,7 +234,7 @@ class PlayerController extends Controller
|
||||||
Request $request,
|
Request $request,
|
||||||
Dispatcher $dispatcher,
|
Dispatcher $dispatcher,
|
||||||
Filter $filter,
|
Filter $filter,
|
||||||
Player $player
|
Player $player,
|
||||||
) {
|
) {
|
||||||
$types = $request->input('type', []);
|
$types = $request->input('type', []);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ class PlayersManagementController extends Controller
|
||||||
public function name(
|
public function name(
|
||||||
Player $player,
|
Player $player,
|
||||||
Request $request,
|
Request $request,
|
||||||
Dispatcher $dispatcher
|
Dispatcher $dispatcher,
|
||||||
) {
|
) {
|
||||||
$name = $request->validate([
|
$name = $request->validate([
|
||||||
'player_name' => [
|
'player_name' => [
|
||||||
|
|
@ -70,7 +70,7 @@ class PlayersManagementController extends Controller
|
||||||
public function owner(
|
public function owner(
|
||||||
Player $player,
|
Player $player,
|
||||||
Request $request,
|
Request $request,
|
||||||
Dispatcher $dispatcher
|
Dispatcher $dispatcher,
|
||||||
) {
|
) {
|
||||||
$uid = $request->validate(['uid' => 'required|integer'])['uid'];
|
$uid = $request->validate(['uid' => 'required|integer'])['uid'];
|
||||||
|
|
||||||
|
|
@ -96,7 +96,7 @@ class PlayersManagementController extends Controller
|
||||||
public function texture(
|
public function texture(
|
||||||
Player $player,
|
Player $player,
|
||||||
Request $request,
|
Request $request,
|
||||||
Dispatcher $dispatcher
|
Dispatcher $dispatcher,
|
||||||
) {
|
) {
|
||||||
$data = $request->validate([
|
$data = $request->validate([
|
||||||
'tid' => 'required|integer',
|
'tid' => 'required|integer',
|
||||||
|
|
@ -123,7 +123,7 @@ class PlayersManagementController extends Controller
|
||||||
|
|
||||||
public function delete(
|
public function delete(
|
||||||
Player $player,
|
Player $player,
|
||||||
Dispatcher $dispatcher
|
Dispatcher $dispatcher,
|
||||||
) {
|
) {
|
||||||
$dispatcher->dispatch('player.deleting', [$player]);
|
$dispatcher->dispatch('player.deleting', [$player]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,7 @@ class ReportController extends Controller
|
||||||
public function review(
|
public function review(
|
||||||
Report $report,
|
Report $report,
|
||||||
Request $request,
|
Request $request,
|
||||||
Dispatcher $dispatcher
|
Dispatcher $dispatcher,
|
||||||
) {
|
) {
|
||||||
$data = $request->validate([
|
$data = $request->validate([
|
||||||
'action' => ['required', Rule::in(['delete', 'ban', 'reject'])],
|
'action' => ['required', Rule::in(['delete', 'ban', 'reject'])],
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ class SetupController extends Controller
|
||||||
Request $request,
|
Request $request,
|
||||||
Filesystem $filesystem,
|
Filesystem $filesystem,
|
||||||
Connection $connection,
|
Connection $connection,
|
||||||
DatabaseManager $manager
|
DatabaseManager $manager,
|
||||||
) {
|
) {
|
||||||
if ($request->isMethod('get')) {
|
if ($request->isMethod('get')) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -121,7 +121,7 @@ class SetupController extends Controller
|
||||||
'database/migrations',
|
'database/migrations',
|
||||||
'vendor/laravel/passport/database/migrations',
|
'vendor/laravel/passport/database/migrations',
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$siteUrl = url('/');
|
$siteUrl = url('/');
|
||||||
if (Str::endsWith($siteUrl, '/index.php')) {
|
if (Str::endsWith($siteUrl, '/index.php')) {
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Models\Texture;
|
use App\Models\Texture;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Auth;
|
|
||||||
use Blessing\Filter;
|
use Blessing\Filter;
|
||||||
use Blessing\Rejection;
|
use Blessing\Rejection;
|
||||||
use Illuminate\Contracts\Events\Dispatcher;
|
use Illuminate\Contracts\Events\Dispatcher;
|
||||||
|
|
@ -12,10 +11,12 @@ use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Filesystem\FilesystemAdapter;
|
use Illuminate\Filesystem\FilesystemAdapter;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\UploadedFile;
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
use Intervention\Image\Facades\Image;
|
||||||
use League\CommonMark\GithubFlavoredMarkdownConverter;
|
use League\CommonMark\GithubFlavoredMarkdownConverter;
|
||||||
use Storage;
|
|
||||||
|
|
||||||
class SkinlibController extends Controller
|
class SkinlibController extends Controller
|
||||||
{
|
{
|
||||||
|
|
@ -189,7 +190,7 @@ class SkinlibController extends Controller
|
||||||
public function handleUpload(
|
public function handleUpload(
|
||||||
Request $request,
|
Request $request,
|
||||||
Filter $filter,
|
Filter $filter,
|
||||||
Dispatcher $dispatcher
|
Dispatcher $dispatcher,
|
||||||
) {
|
) {
|
||||||
$file = $request->file('file');
|
$file = $request->file('file');
|
||||||
if ($file && !$file->isValid()) {
|
if ($file && !$file->isValid()) {
|
||||||
|
|
@ -220,6 +221,16 @@ class SkinlibController extends Controller
|
||||||
$type = $data['type'];
|
$type = $data['type'];
|
||||||
$size = getimagesize($file);
|
$size = getimagesize($file);
|
||||||
|
|
||||||
|
$maxWidth = option('max_texture_width', 8192);
|
||||||
|
if ($size[0] > $maxWidth) {
|
||||||
|
$message = trans('skinlib.upload.too-wide', [
|
||||||
|
'width' => $size[0],
|
||||||
|
'maxWidth' => $maxWidth,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return json($message, 1);
|
||||||
|
}
|
||||||
|
|
||||||
if ($size[0] % 64 != 0 || $size[1] % 32 != 0) {
|
if ($size[0] % 64 != 0 || $size[1] % 32 != 0) {
|
||||||
$message = trans('skinlib.upload.invalid-size', [
|
$message = trans('skinlib.upload.invalid-size', [
|
||||||
'type' => $type === 'cape' ? trans('general.cape') : trans('general.skin'),
|
'type' => $type === 'cape' ? trans('general.cape') : trans('general.skin'),
|
||||||
|
|
@ -253,8 +264,17 @@ class SkinlibController extends Controller
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$hash = hash_file('sha256', $file);
|
$image = Image::make($file);
|
||||||
$hash = $filter->apply('uploaded_texture_hash', $hash, [$file]);
|
$imagick = $image->getCore();
|
||||||
|
$imagick->setOption('png:compression-filter', '0');
|
||||||
|
$imagick->setOption('png:compression-level', '9');
|
||||||
|
$imagick->setOption('png:compression-strategy', '0');
|
||||||
|
$imagick->setOption('png:exclude-chunk', 'all');
|
||||||
|
$imagick->stripImage();
|
||||||
|
$sanitized = $image->encode('png')->getEncoded();
|
||||||
|
|
||||||
|
$hash = hash('sha256', $image->encoded);
|
||||||
|
$hash = $filter->apply('uploaded_texture_hash', $hash, [$image]);
|
||||||
|
|
||||||
/** @var User */
|
/** @var User */
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
|
|
@ -270,11 +290,11 @@ class SkinlibController extends Controller
|
||||||
return json(trans('skinlib.upload.repeated'), 2, ['tid' => $duplicated->tid]);
|
return json(trans('skinlib.upload.repeated'), 2, ['tid' => $duplicated->tid]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$size = ceil($file->getSize() / 1024);
|
$fileSize = ceil(strlen($sanitized) / 1024);
|
||||||
$isPublic = is_string($data['public'])
|
$isPublic = is_string($data['public'])
|
||||||
? $data['public'] === '1'
|
? $data['public'] === '1'
|
||||||
: $data['public'];
|
: $data['public'];
|
||||||
$cost = $size * (
|
$cost = $fileSize * (
|
||||||
$isPublic
|
$isPublic
|
||||||
? option('score_per_storage')
|
? option('score_per_storage')
|
||||||
: option('private_score_per_storage')
|
: option('private_score_per_storage')
|
||||||
|
|
@ -285,13 +305,13 @@ class SkinlibController extends Controller
|
||||||
return json(trans('skinlib.upload.lack-score'), 1);
|
return json(trans('skinlib.upload.lack-score'), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
$dispatcher->dispatch('texture.uploading', [$file, $name, $hash]);
|
$dispatcher->dispatch('texture.uploading', [$image, $name, $hash]);
|
||||||
|
|
||||||
$texture = new Texture();
|
$texture = new Texture();
|
||||||
$texture->name = $name;
|
$texture->name = $name;
|
||||||
$texture->type = $type;
|
$texture->type = $type;
|
||||||
$texture->hash = $hash;
|
$texture->hash = $hash;
|
||||||
$texture->size = $size;
|
$texture->size = $fileSize;
|
||||||
$texture->public = $isPublic;
|
$texture->public = $isPublic;
|
||||||
$texture->uploader = $user->uid;
|
$texture->uploader = $user->uid;
|
||||||
$texture->likes = 1;
|
$texture->likes = 1;
|
||||||
|
|
@ -300,14 +320,14 @@ class SkinlibController extends Controller
|
||||||
/** @var FilesystemAdapter */
|
/** @var FilesystemAdapter */
|
||||||
$disk = Storage::disk('textures');
|
$disk = Storage::disk('textures');
|
||||||
if ($disk->missing($hash)) {
|
if ($disk->missing($hash)) {
|
||||||
$file->storePubliclyAs('', $hash, ['disk' => 'textures']);
|
$disk->put($hash, $sanitized);
|
||||||
}
|
}
|
||||||
|
|
||||||
$user->score -= $cost;
|
$user->score -= $cost;
|
||||||
$user->closet()->attach($texture->tid, ['item_name' => $name]);
|
$user->closet()->attach($texture->tid, ['item_name' => $name]);
|
||||||
$user->save();
|
$user->save();
|
||||||
|
|
||||||
$dispatcher->dispatch('texture.uploaded', [$texture, $file]);
|
$dispatcher->dispatch('texture.uploaded', [$texture, $image]);
|
||||||
|
|
||||||
return json(trans('skinlib.upload.success', ['name' => $name]), 0, [
|
return json(trans('skinlib.upload.success', ['name' => $name]), 0, [
|
||||||
'tid' => $texture->tid,
|
'tid' => $texture->tid,
|
||||||
|
|
@ -386,7 +406,7 @@ class SkinlibController extends Controller
|
||||||
Request $request,
|
Request $request,
|
||||||
Dispatcher $dispatcher,
|
Dispatcher $dispatcher,
|
||||||
Filter $filter,
|
Filter $filter,
|
||||||
Texture $texture
|
Texture $texture,
|
||||||
) {
|
) {
|
||||||
$data = $request->validate(['name' => [
|
$data = $request->validate(['name' => [
|
||||||
'required',
|
'required',
|
||||||
|
|
@ -416,7 +436,7 @@ class SkinlibController extends Controller
|
||||||
Request $request,
|
Request $request,
|
||||||
Dispatcher $dispatcher,
|
Dispatcher $dispatcher,
|
||||||
Filter $filter,
|
Filter $filter,
|
||||||
Texture $texture
|
Texture $texture,
|
||||||
) {
|
) {
|
||||||
$data = $request->validate([
|
$data = $request->validate([
|
||||||
'type' => ['required', Rule::in(['steve', 'alex', 'cape'])],
|
'type' => ['required', Rule::in(['steve', 'alex', 'cape'])],
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,11 @@ use App\Models\Player;
|
||||||
use App\Models\Texture;
|
use App\Models\Texture;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Blessing\Minecraft;
|
use Blessing\Minecraft;
|
||||||
use Cache;
|
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Image;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Intervention\Image\Facades\Image;
|
||||||
|
|
||||||
class TextureController extends Controller
|
class TextureController extends Controller
|
||||||
{
|
{
|
||||||
|
|
@ -71,7 +71,8 @@ class TextureController extends Controller
|
||||||
|
|
||||||
$lastModified = $disk->lastModified($hash);
|
$lastModified = $disk->lastModified($hash);
|
||||||
|
|
||||||
return Image::make($image)
|
// TODO: refactor
|
||||||
|
return \Intervention\Image\ImageManagerStatic::configure(['driver' => 'gd'])->make($image)
|
||||||
->response($usePNG ? 'png' : 'webp', 100)
|
->response($usePNG ? 'png' : 'webp', 100)
|
||||||
->setLastModified(Carbon::createFromTimestamp($lastModified));
|
->setLastModified(Carbon::createFromTimestamp($lastModified));
|
||||||
}
|
}
|
||||||
|
|
@ -145,7 +146,8 @@ class TextureController extends Controller
|
||||||
|
|
||||||
$disk = Storage::disk('textures');
|
$disk = Storage::disk('textures');
|
||||||
if (is_null($texture) || $disk->missing($texture->hash)) {
|
if (is_null($texture) || $disk->missing($texture->hash)) {
|
||||||
return Image::make(resource_path("misc/textures/avatar$mode.png"))
|
// TODO: refactor
|
||||||
|
return \Intervention\Image\ImageManagerStatic::configure(['driver' => 'gd'])->make(resource_path("misc/textures/avatar$mode.png"))
|
||||||
->resize($size, $size)
|
->resize($size, $size)
|
||||||
->response($usePNG ? 'png' : 'webp', 100);
|
->response($usePNG ? 'png' : 'webp', 100);
|
||||||
}
|
}
|
||||||
|
|
@ -165,7 +167,8 @@ class TextureController extends Controller
|
||||||
|
|
||||||
$lastModified = Carbon::createFromTimestamp($disk->lastModified($hash));
|
$lastModified = Carbon::createFromTimestamp($disk->lastModified($hash));
|
||||||
|
|
||||||
return Image::make($image)
|
// TODO: refactor
|
||||||
|
return \Intervention\Image\ImageManagerStatic::configure(['driver' => 'gd'])->make($image)
|
||||||
->resize($size, $size)
|
->resize($size, $size)
|
||||||
->response($usePNG ? 'png' : 'webp', 100)
|
->response($usePNG ? 'png' : 'webp', 100)
|
||||||
->setLastModified($lastModified);
|
->setLastModified($lastModified);
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ class TranslationsController extends Controller
|
||||||
Request $request,
|
Request $request,
|
||||||
Application $app,
|
Application $app,
|
||||||
JavaScript $js,
|
JavaScript $js,
|
||||||
LanguageLine $line
|
LanguageLine $line,
|
||||||
) {
|
) {
|
||||||
$data = $request->validate(['text' => 'required|string']);
|
$data = $request->validate(['text' => 'required|string']);
|
||||||
|
|
||||||
|
|
@ -57,7 +57,7 @@ class TranslationsController extends Controller
|
||||||
public function delete(
|
public function delete(
|
||||||
Application $app,
|
Application $app,
|
||||||
JavaScript $js,
|
JavaScript $js,
|
||||||
LanguageLine $line
|
LanguageLine $line,
|
||||||
) {
|
) {
|
||||||
$line->delete();
|
$line->delete();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,16 +6,16 @@ use App\Events\UserProfileUpdated;
|
||||||
use App\Mail\EmailVerification;
|
use App\Mail\EmailVerification;
|
||||||
use App\Models\Texture;
|
use App\Models\Texture;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Auth;
|
|
||||||
use Blessing\Filter;
|
use Blessing\Filter;
|
||||||
use Blessing\Rejection;
|
use Blessing\Rejection;
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
use Illuminate\Contracts\Events\Dispatcher;
|
use Illuminate\Contracts\Events\Dispatcher;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
use Illuminate\Support\Facades\Session;
|
||||||
|
use Illuminate\Support\Facades\URL;
|
||||||
use League\CommonMark\GithubFlavoredMarkdownConverter;
|
use League\CommonMark\GithubFlavoredMarkdownConverter;
|
||||||
use Mail;
|
|
||||||
use Session;
|
|
||||||
use URL;
|
|
||||||
|
|
||||||
class UserController extends Controller
|
class UserController extends Controller
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,10 @@ use Illuminate\Http\Request;
|
||||||
class CheckRole
|
class CheckRole
|
||||||
{
|
{
|
||||||
protected $roles = [
|
protected $roles = [
|
||||||
'banned' => USER::BANNED,
|
'banned' => User::BANNED,
|
||||||
'normal' => USER::NORMAL,
|
'normal' => User::NORMAL,
|
||||||
'admin' => USER::ADMIN,
|
'admin' => User::ADMIN,
|
||||||
'super-admin' => USER::SUPER_ADMIN,
|
'super-admin' => User::SUPER_ADMIN,
|
||||||
];
|
];
|
||||||
|
|
||||||
public function handle(Request $request, Closure $next, $role)
|
public function handle(Request $request, Closure $next, $role)
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,9 @@ class DetectLanguagePrefer
|
||||||
|
|
||||||
/** @var Response */
|
/** @var Response */
|
||||||
$response = $next($request);
|
$response = $next($request);
|
||||||
$response->cookie('locale', $locale, 120);
|
if (!in_array('api', optional($request->route())->middleware() ?? [])) {
|
||||||
|
$response->cookie('locale', $locale, 120);
|
||||||
|
}
|
||||||
|
|
||||||
return $response;
|
return $response;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ class FootComposer
|
||||||
Request $request,
|
Request $request,
|
||||||
JavaScript $javascript,
|
JavaScript $javascript,
|
||||||
Dispatcher $dispatcher,
|
Dispatcher $dispatcher,
|
||||||
Filter $filter
|
Filter $filter,
|
||||||
) {
|
) {
|
||||||
$this->request = $request;
|
$this->request = $request;
|
||||||
$this->javascript = $javascript;
|
$this->javascript = $javascript;
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ class HeadComposer
|
||||||
public function __construct(
|
public function __construct(
|
||||||
Dispatcher $dispatcher,
|
Dispatcher $dispatcher,
|
||||||
Request $request,
|
Request $request,
|
||||||
Filter $filter
|
Filter $filter,
|
||||||
) {
|
) {
|
||||||
$this->dispatcher = $dispatcher;
|
$this->dispatcher = $dispatcher;
|
||||||
$this->request = $request;
|
$this->request = $request;
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ class UserMenuComposer
|
||||||
['label' => trans('general.admin-panel'), 'link' => route('admin.view')],
|
['label' => trans('general.admin-panel'), 'link' => route('admin.view')],
|
||||||
['label' => trans('general.user-manage'), 'link' => route('admin.users.view')],
|
['label' => trans('general.user-manage'), 'link' => route('admin.users.view')],
|
||||||
['label' => trans('general.report-manage'), 'link' => route('admin.reports.view')],
|
['label' => trans('general.report-manage'), 'link' => route('admin.reports.view')],
|
||||||
|
['label' => 'Web CLI', 'link' => '#launch-cli'],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
$menuItems = $this->filter->apply('user_menu', $menuItems, [$user]);
|
$menuItems = $this->filter->apply('user_menu', $menuItems, [$user]);
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
namespace App\Listeners;
|
namespace App\Listeners;
|
||||||
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Event;
|
use Illuminate\Support\Facades\Event;
|
||||||
|
|
||||||
class NotifyFailedPlugin
|
class NotifyFailedPlugin
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ namespace App\Mail;
|
||||||
|
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Mail\Mailable;
|
use Illuminate\Mail\Mailable;
|
||||||
|
use Illuminate\Mail\Mailables\Headers;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
class EmailVerification extends Mailable
|
class EmailVerification extends Mailable
|
||||||
|
|
@ -26,4 +27,13 @@ class EmailVerification extends Mailable
|
||||||
->subject(trans('user.verification.mail.title', ['sitename' => $site_name]))
|
->subject(trans('user.verification.mail.title', ['sitename' => $site_name]))
|
||||||
->view('mails.email-verification');
|
->view('mails.email-verification');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function headers(): Headers
|
||||||
|
{
|
||||||
|
return new Headers(
|
||||||
|
text: [
|
||||||
|
'Auto-Submitted' => 'auto-generated',
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ namespace App\Mail;
|
||||||
|
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Mail\Mailable;
|
use Illuminate\Mail\Mailable;
|
||||||
|
use Illuminate\Mail\Mailables\Headers;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
class ForgotPassword extends Mailable
|
class ForgotPassword extends Mailable
|
||||||
|
|
@ -26,4 +27,13 @@ class ForgotPassword extends Mailable
|
||||||
->subject(trans('auth.forgot.mail.title', ['sitename' => $site_name]))
|
->subject(trans('auth.forgot.mail.title', ['sitename' => $site_name]))
|
||||||
->view('mails.password-reset');
|
->view('mails.password-reset');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function headers(): Headers
|
||||||
|
{
|
||||||
|
return new Headers(
|
||||||
|
text: [
|
||||||
|
'Auto-Submitted' => 'auto-generated',
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use App\Events\PlayerProfileUpdated;
|
use App\Events\PlayerProfileUpdated;
|
||||||
use App\Models;
|
|
||||||
use DateTimeInterface;
|
use DateTimeInterface;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
@ -57,17 +56,17 @@ class Player extends Model
|
||||||
|
|
||||||
public function user()
|
public function user()
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Models\User::class, 'uid');
|
return $this->belongsTo(User::class, 'uid');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function skin()
|
public function skin()
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Models\Texture::class, 'tid_skin');
|
return $this->belongsTo(Texture::class, 'tid_skin');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function cape()
|
public function cape()
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Models\Texture::class, 'tid_cape');
|
return $this->belongsTo(Texture::class, 'tid_cape');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getModelAttribute()
|
public function getModelAttribute()
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,10 @@
|
||||||
|
|
||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use App\Models\Scope;
|
||||||
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
|
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Laravel\Passport\Passport;
|
use Laravel\Passport\Passport;
|
||||||
|
|
||||||
class AuthServiceProvider extends ServiceProvider
|
class AuthServiceProvider extends ServiceProvider
|
||||||
|
|
@ -39,7 +41,19 @@ class AuthServiceProvider extends ServiceProvider
|
||||||
'ReportsManagement.ReadWrite' => 'auth.oauth.scope.reports-management.readwrite',
|
'ReportsManagement.ReadWrite' => 'auth.oauth.scope.reports-management.readwrite',
|
||||||
];
|
];
|
||||||
|
|
||||||
$scopes = Cache::get('scopes', []);
|
/*
|
||||||
|
* Return empty scopes if running unit tests or before installation.
|
||||||
|
* In these cases, migrations aren’t run yet, so DB queries will fail.
|
||||||
|
* OAuth isn’t tested in unit tests, so returning empty scopes should be fine...?
|
||||||
|
* Maybe the best approach is to run migrations before bootstrap in tests,
|
||||||
|
* but this seems impossible for DB_DATABASE=:memory:;
|
||||||
|
* Or change how scopes are registered so they don't depend on the database,
|
||||||
|
* but that may introduce BREAKING CHANGES and plugin incompatibility.
|
||||||
|
* PRs welcome for better solutions!
|
||||||
|
*/
|
||||||
|
$scopes = (app()->runningUnitTests() || !Storage::disk('root')->exists('storage/install.lock')) ? [] : Cache::rememberForever('scopes', function () {
|
||||||
|
return Scope::pluck('description', 'name')->toArray();
|
||||||
|
});
|
||||||
|
|
||||||
Passport::tokensCan(array_merge($defaultScopes, $scopes));
|
Passport::tokensCan(array_merge($defaultScopes, $scopes));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,10 @@ use App\Events;
|
||||||
use App\Notifications;
|
use App\Notifications;
|
||||||
use Blessing\Filter;
|
use Blessing\Filter;
|
||||||
use Closure;
|
use Closure;
|
||||||
use Event;
|
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Support\Facades\Event;
|
||||||
|
use Illuminate\Support\Facades\Notification;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Notification;
|
|
||||||
|
|
||||||
class Hook
|
class Hook
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,10 @@
|
||||||
|
|
||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
use DB;
|
|
||||||
use Illuminate\Database\QueryException;
|
use Illuminate\Database\QueryException;
|
||||||
use Illuminate\Filesystem\Filesystem;
|
use Illuminate\Filesystem\Filesystem;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
class Option
|
class Option
|
||||||
{
|
{
|
||||||
|
|
@ -20,13 +20,14 @@ class Option
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
if (!file_exists(storage_path('install.lock')) || app()->runningUnitTests()) {
|
||||||
$this->items = DB::table('options')
|
|
||||||
->get()
|
|
||||||
->mapWithKeys(fn ($item) => [$item->option_name => $item->option_value]);
|
|
||||||
} catch (QueryException $e) {
|
|
||||||
$this->items = collect();
|
$this->items = collect();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->items = DB::table('options')
|
||||||
|
->get()
|
||||||
|
->mapWithKeys(fn ($item) => [$item->option_name => $item->option_value]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function get($key, $default = null, $raw = false)
|
public function get($key, $default = null, $raw = false)
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,10 @@
|
||||||
|
|
||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Services\Facades\Option;
|
||||||
use BadMethodCallException;
|
use BadMethodCallException;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Option;
|
|
||||||
use ReflectionClass;
|
use ReflectionClass;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -203,7 +203,7 @@ class OptionForm
|
||||||
/**
|
/**
|
||||||
* Handle the HTTP post request and update modified options.
|
* Handle the HTTP post request and update modified options.
|
||||||
*/
|
*/
|
||||||
public function handle(callable $callback = null): self
|
public function handle(?callable $callback = null): self
|
||||||
{
|
{
|
||||||
$request = request();
|
$request = request();
|
||||||
$allPostData = $request->all();
|
$allPostData = $request->all();
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ class PluginManager
|
||||||
Application $app,
|
Application $app,
|
||||||
Option $option,
|
Option $option,
|
||||||
Dispatcher $dispatcher,
|
Dispatcher $dispatcher,
|
||||||
Filesystem $filesystem
|
Filesystem $filesystem,
|
||||||
) {
|
) {
|
||||||
$this->app = $app;
|
$this->app = $app;
|
||||||
$this->option = $option;
|
$this->option = $option;
|
||||||
|
|
@ -366,7 +366,7 @@ class PluginManager
|
||||||
*/
|
*/
|
||||||
public function formatUnresolved(
|
public function formatUnresolved(
|
||||||
Collection $unsatisfied,
|
Collection $unsatisfied,
|
||||||
Collection $conflicts
|
Collection $conflicts,
|
||||||
): array {
|
): array {
|
||||||
$unsatisfied = $unsatisfied->map(function ($detail, $name) {
|
$unsatisfied = $unsatisfied->map(function ($detail, $name) {
|
||||||
if ($name === 'blessing-skin-server') {
|
if ($name === 'blessing-skin-server') {
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ class JavaScript
|
||||||
public function __construct(
|
public function __construct(
|
||||||
Filesystem $filesystem,
|
Filesystem $filesystem,
|
||||||
Repository $cache,
|
Repository $cache,
|
||||||
PluginManager $plugins
|
PluginManager $plugins,
|
||||||
) {
|
) {
|
||||||
$this->filesystem = $filesystem;
|
$this->filesystem = $filesystem;
|
||||||
$this->cache = $cache;
|
$this->cache = $cache;
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ ini_set('display_errors', true);
|
||||||
'json',
|
'json',
|
||||||
'fileinfo',
|
'fileinfo',
|
||||||
'zip',
|
'zip',
|
||||||
|
'imagick',
|
||||||
],
|
],
|
||||||
'write_permission' => [
|
'write_permission' => [
|
||||||
'bootstrap/cache',
|
'bootstrap/cache',
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
"php": "^8.1",
|
"php": "^8.1",
|
||||||
"ext-ctype": "*",
|
"ext-ctype": "*",
|
||||||
"ext-gd": "*",
|
"ext-gd": "*",
|
||||||
|
"ext-imagick": "*",
|
||||||
"ext-json": "*",
|
"ext-json": "*",
|
||||||
"ext-mbstring": "*",
|
"ext-mbstring": "*",
|
||||||
"ext-openssl": "*",
|
"ext-openssl": "*",
|
||||||
|
|
@ -29,7 +30,7 @@
|
||||||
"lorisleiva/laravel-search-string": "^1.0",
|
"lorisleiva/laravel-search-string": "^1.0",
|
||||||
"nesbot/carbon": "^2.0",
|
"nesbot/carbon": "^2.0",
|
||||||
"nunomaduro/collision": "^7.0",
|
"nunomaduro/collision": "^7.0",
|
||||||
"rcrowe/twigbridge": "dev-blessing",
|
"rcrowe/twigbridge": "^0.14",
|
||||||
"spatie/laravel-translation-loader": "^2.7",
|
"spatie/laravel-translation-loader": "^2.7",
|
||||||
"symfony/process": "^6.0",
|
"symfony/process": "^6.0",
|
||||||
"symfony/yaml": "^5.0",
|
"symfony/yaml": "^5.0",
|
||||||
|
|
@ -81,14 +82,10 @@
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"repositories": [
|
"repositories": {
|
||||||
{
|
"packagist": {
|
||||||
"type": "vcs",
|
|
||||||
"url": "https://github.com/bs-community/TwigBridge"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "composer",
|
"type": "composer",
|
||||||
"url": "https://packagist.org/"
|
"url": "https://packagist.org/"
|
||||||
}
|
}
|
||||||
]
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1829
composer.lock
generated
1829
composer.lock
generated
File diff suppressed because it is too large
Load Diff
20
config/image.php
Normal file
20
config/image.php
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Image Driver
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Intervention Image supports "GD Library" and "Imagick" to process images
|
||||||
|
| internally. You may choose one of them according to your PHP
|
||||||
|
| configuration. By default PHP's "GD Library" implementation is used.
|
||||||
|
|
|
||||||
|
| Supported: "gd", "imagick"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'driver' => 'imagick'
|
||||||
|
|
||||||
|
];
|
||||||
|
|
@ -33,7 +33,7 @@ return [
|
||||||
*/
|
*/
|
||||||
'registry' => env(
|
'registry' => env(
|
||||||
'PLUGINS_REGISTRY',
|
'PLUGINS_REGISTRY',
|
||||||
'https://d2jw1l0ullrzt6.cloudfront.net/registry_{lang}.json'
|
'https://bs-plugins.littleservice.cn/registry_{lang}.json'
|
||||||
),
|
),
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,6 @@ return [
|
||||||
|
|
||||||
// 'TwigBridge\Extension\Laravel\Form',
|
// 'TwigBridge\Extension\Laravel\Form',
|
||||||
// 'TwigBridge\Extension\Laravel\Html',
|
// 'TwigBridge\Extension\Laravel\Html',
|
||||||
'TwigBridge\Extension\Laravel\Vite',
|
|
||||||
// 'TwigBridge\Extension\Laravel\Legacy\Facades',
|
// 'TwigBridge\Extension\Laravel\Legacy\Facades',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|
@ -154,8 +153,7 @@ return [
|
||||||
| in order to be marked as safe.
|
| in order to be marked as safe.
|
||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
'facades' => [
|
'facades' => [],
|
||||||
],
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
use Illuminate\Database\Migrations\Migration;
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
class CreateAllTables extends Migration
|
class CreateAllTables extends Migration
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Services\Facades\Option;
|
||||||
use Illuminate\Database\Migrations\Migration;
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
class ImportOptions extends Migration
|
class ImportOptions extends Migration
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
use Illuminate\Database\Migrations\Migration;
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
class AddVerificationToUsersTable extends Migration
|
class AddVerificationToUsersTable extends Migration
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Services\Facades\Option;
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
|
||||||
|
return new class extends Migration {
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Option::set('max_texture_width', 8192);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
import {configBuilder} from '@mochaa/eslintrc';
|
|
||||||
|
|
||||||
export default configBuilder({
|
|
||||||
ignores: [
|
|
||||||
'public/',
|
|
||||||
'vendor/',
|
|
||||||
'vendor/',
|
|
||||||
'plugins/',
|
|
||||||
'storage/',
|
|
||||||
],
|
|
||||||
});
|
|
||||||
201
package.json
201
package.json
|
|
@ -1,90 +1,163 @@
|
||||||
{
|
{
|
||||||
"name": "blessing-skin-server",
|
"name": "blessing-skin-server",
|
||||||
"type": "module",
|
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"private": true,
|
|
||||||
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e",
|
|
||||||
"description": "A web application brings your custom skins back in offline Minecraft servers.",
|
"description": "A web application brings your custom skins back in offline Minecraft servers.",
|
||||||
"author": "printempw",
|
|
||||||
"license": "MIT",
|
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/bs-community/blessing-skin-server"
|
"url": "https://github.com/bs-community/blessing-skin-server"
|
||||||
},
|
},
|
||||||
|
"author": "printempw",
|
||||||
|
"license": "MIT",
|
||||||
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "vite build",
|
"dev": "webpack serve",
|
||||||
|
"build": "webpack --env production --progress",
|
||||||
|
"lint": "eslint --ext=ts -f=beauty .",
|
||||||
|
"fmt": "prettier --write resources/assets tools webpack.config.ts",
|
||||||
|
"fmt:check": "prettier --check resources/assets tools webpack.config.ts",
|
||||||
|
"type:check": "tsc -p . --noEmit && tsc -p ./resources/assets/tests --noEmit",
|
||||||
|
"test": "jest",
|
||||||
"build:urls": "ts-node tools/generateUrls.ts",
|
"build:urls": "ts-node tools/generateUrls.ts",
|
||||||
"dev": "vite",
|
"prepare": "husky install"
|
||||||
"lint": "eslint .",
|
|
||||||
"test": "vitest"
|
|
||||||
},
|
},
|
||||||
"browserslist": [
|
|
||||||
"Firefox ESR",
|
|
||||||
"iOS >= 12.5",
|
|
||||||
"Chrome >= 87"
|
|
||||||
],
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.0.0",
|
||||||
"@emotion/styled": "^11.14.0",
|
"@emotion/styled": "^11.0.0",
|
||||||
"@fortawesome/fontawesome-free": "^6.7.2",
|
"@fortawesome/fontawesome-free": "^6.3.0",
|
||||||
"@tweenjs/tween.js": "^25.0.0",
|
"@hot-loader/react-dom": "^17.0.0",
|
||||||
"admin-lte": "4.0.0-beta3",
|
"@tweenjs/tween.js": "^18.5.0",
|
||||||
"bootstrap": "^5.3.3",
|
"admin-lte": "^3.2.0",
|
||||||
"clsx": "^2.1.1",
|
"blessing-skin-shell": "^0.3.4",
|
||||||
"downshift": "^9.0.8",
|
"bootstrap": "^4.6.1",
|
||||||
"echarts": "^5.6.0",
|
"cac": "6.6.1",
|
||||||
"immer": "^10.1.1",
|
"cli-spinners": "^2.5.0",
|
||||||
|
"clsx": "^1.1.1",
|
||||||
|
"echarts": "^5.1.2",
|
||||||
|
"events": "^3.2.0",
|
||||||
|
"immer": "^7.0.4",
|
||||||
"jquery": "^3.6.0",
|
"jquery": "^3.6.0",
|
||||||
"lodash-es": "^4.0.8",
|
"lodash.debounce": "^4.0.8",
|
||||||
"nanoid": "^5.0.9",
|
"nanoid": "^3.1.9",
|
||||||
"prompts": "^2.4.0",
|
"prompts": "^2.4.0",
|
||||||
"react": "^18.0.0",
|
"react": "^17.0.1",
|
||||||
"react-dom": "^18.0.0",
|
"react-autosuggest": "^10.0.2",
|
||||||
|
"react-dom": "^17.0.1",
|
||||||
"react-draggable": "^4.4.2",
|
"react-draggable": "^4.4.2",
|
||||||
"react-loading-skeleton": "^3.5.0",
|
"react-hot-loader": "^4.12.21",
|
||||||
"react-use": "^17.6.0",
|
"react-loading-skeleton": "^2.1.1",
|
||||||
|
"react-use": "^17.4.0",
|
||||||
"reaptcha": "^1.7.2",
|
"reaptcha": "^1.7.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^6.5.5",
|
||||||
"skinview-utils": "^0.7.1",
|
"skinview-utils": "^0.5.5",
|
||||||
"skinview3d": "^3.1.0",
|
"skinview3d": "^3.0.0-alpha.1",
|
||||||
"spectre.css": "github:angular-package/spectre.css",
|
"spectre.css": "^0.5.8",
|
||||||
"use-immer": "^0.11.0"
|
"use-immer": "^0.4.2",
|
||||||
|
"xterm": "^4.6.0",
|
||||||
|
"xterm-addon-fit": "^0.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint-react/eslint-plugin": "^1.23.2",
|
"@gplane/tsconfig": "^4.2.0",
|
||||||
"@mochaa/eslintrc": "^0.1.12",
|
"@testing-library/jest-dom": "^5.11.10",
|
||||||
"@testing-library/jest-dom": "^6.6.3",
|
"@testing-library/react": "^11.2.6",
|
||||||
"@testing-library/react": "^16.2.0",
|
"@types/bootstrap": "^4.3.3",
|
||||||
"@tsconfig/vite-react": "^3.4.0",
|
"@types/css-minimizer-webpack-plugin": "^1.1.0",
|
||||||
"@types/bootstrap": "^5.2.10",
|
"@types/jest": "^26.0.23",
|
||||||
"@types/jquery": "^3.5.32",
|
"@types/jquery": "^3.5.13",
|
||||||
"@types/js-yaml": "^4.0.9",
|
"@types/js-yaml": "^3.12.4",
|
||||||
"@types/lodash-es": "^4.0.6",
|
"@types/lodash.debounce": "^4.0.6",
|
||||||
|
"@types/mini-css-extract-plugin": "^1.2.1",
|
||||||
"@types/prompts": "^2.0.9",
|
"@types/prompts": "^2.0.9",
|
||||||
"@types/react": "^18",
|
"@types/react": "^16.9.35",
|
||||||
"@types/react-dom": "^18",
|
"@types/react-autosuggest": "^9.3.14",
|
||||||
|
"@types/react-dom": "^16.9.8",
|
||||||
"@types/tween.js": "^18.5.0",
|
"@types/tween.js": "^18.5.0",
|
||||||
"@vitejs/plugin-react-swc": "^3.7.2",
|
"@types/webpack-dev-server": "^3.11.0",
|
||||||
"autoprefixer": "^10.4.20",
|
"@typescript-eslint/eslint-plugin": "^3.6.0",
|
||||||
"browserslist": "^4.24.4",
|
"@typescript-eslint/parser": "^3.6.0",
|
||||||
"browserslist-to-esbuild": "^2.1.1",
|
"autoprefixer": "^10.2.6",
|
||||||
"eslint": "^9.18.0",
|
"css-loader": "^5.2.6",
|
||||||
"eslint-plugin-react-hooks": "^5.1.0",
|
"css-minimizer-webpack-plugin": "^3.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.4.18",
|
"eslint": "^7.4.0",
|
||||||
"js-yaml": "^4.1.0",
|
"eslint-formatter-beauty": "^3.0.0",
|
||||||
"laravel-vite-plugin": "^1.1.1",
|
"eslint-plugin-react-hooks": "^4.3.0",
|
||||||
"postcss": "^8.5.1",
|
"html-webpack-plugin": "^5.3.1",
|
||||||
"sass": "^1.83.4",
|
"husky": "^7.0.4",
|
||||||
"typescript": "^5.7.3",
|
"jest": "^27.0.4",
|
||||||
"vite": "^6.0.7",
|
"jest-extended": "^0.11.5",
|
||||||
"vitest": "^3.0.2"
|
"js-yaml": "^3.13.1",
|
||||||
|
"mini-css-extract-plugin": "^1.6.0",
|
||||||
|
"postcss": "^8.3.0",
|
||||||
|
"postcss-loader": "^5.3.0",
|
||||||
|
"prettier": "^2.3.0",
|
||||||
|
"pretty-quick": "^3.1.3",
|
||||||
|
"style-loader": "^2.0.0",
|
||||||
|
"ts-jest": "^27.0.2",
|
||||||
|
"ts-loader": "^9.2.2",
|
||||||
|
"ts-node": "^10.0.0",
|
||||||
|
"typescript": "^4.3.2",
|
||||||
|
"webpack": "^5.38.1",
|
||||||
|
"webpack-cli": "^4.7.0",
|
||||||
|
"webpack-dev-server": "^3.11.2"
|
||||||
},
|
},
|
||||||
"resolutions": {
|
"resolutions": {
|
||||||
"kleur": "^4.1.3"
|
"kleur": "^4.1.3"
|
||||||
},
|
},
|
||||||
"postcss": {
|
"browserslist": [
|
||||||
"plugins": {
|
"> 1%",
|
||||||
"autoprefixer": {}
|
"not dead",
|
||||||
|
"not ie 11",
|
||||||
|
"Chrome > 52"
|
||||||
|
],
|
||||||
|
"prettier": {
|
||||||
|
"printWidth": 80,
|
||||||
|
"semi": false,
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"tabWidth": 2
|
||||||
|
},
|
||||||
|
"jest": {
|
||||||
|
"preset": "ts-jest",
|
||||||
|
"resetMocks": true,
|
||||||
|
"testEnvironment": "jsdom",
|
||||||
|
"moduleFileExtensions": [
|
||||||
|
"js",
|
||||||
|
"ts",
|
||||||
|
"tsx",
|
||||||
|
"json",
|
||||||
|
"node"
|
||||||
|
],
|
||||||
|
"moduleNameMapper": {
|
||||||
|
"\\.css$": "<rootDir>/resources/assets/tests/__mocks__/style.ts",
|
||||||
|
"\\.(png|webp)$": "<rootDir>/resources/assets/tests/__mocks__/file.ts",
|
||||||
|
"^@/(.*)$": "<rootDir>/resources/assets/src/$1"
|
||||||
|
},
|
||||||
|
"setupFilesAfterEnv": [
|
||||||
|
"<rootDir>/resources/assets/tests/setup.ts"
|
||||||
|
],
|
||||||
|
"coveragePathIgnorePatterns": [
|
||||||
|
"/node_modules/",
|
||||||
|
"<rootDir>/resources/assets/src/styles",
|
||||||
|
"<rootDir>/resources/assets/src/scripts/extra.ts",
|
||||||
|
"<rootDir>/resources/assets/src/scripts/urls.ts",
|
||||||
|
"<rootDir>/resources/assets/tests/setup",
|
||||||
|
"<rootDir>/resources/assets/tests/utils",
|
||||||
|
"<rootDir>/resources/assets/tests/scripts/cli/stdio"
|
||||||
|
],
|
||||||
|
"testMatch": [
|
||||||
|
"<rootDir>/resources/assets/tests/**/*.test.ts",
|
||||||
|
"<rootDir>/resources/assets/tests/**/*.test.tsx"
|
||||||
|
],
|
||||||
|
"testPathIgnorePatterns": [
|
||||||
|
"/node_modules/",
|
||||||
|
"<rootDir>/resources/assets/tests/(views|components)/.*\\.ts$"
|
||||||
|
],
|
||||||
|
"maxWorkers": "50%",
|
||||||
|
"globals": {
|
||||||
|
"ts-jest": {
|
||||||
|
"tsconfig": "<rootDir>/resources/assets/tests/tsconfig.json",
|
||||||
|
"isolatedModules": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
5
postcss.config.js
Normal file
5
postcss.config.js
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
module.exports = {
|
||||||
|
plugins: [
|
||||||
|
require('autoprefixer'),
|
||||||
|
],
|
||||||
|
}
|
||||||
2
public/.gitignore
vendored
2
public/.gitignore
vendored
|
|
@ -1,3 +1 @@
|
||||||
app/
|
app/
|
||||||
build/
|
|
||||||
hot
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
@import '@/styles/common.css';
|
|
||||||
@import 'admin-lte/src/scss/adminlte.scss';
|
|
||||||
@import '@fortawesome/fontawesome-free/scss/fontawesome.scss';
|
|
||||||
@import '@fortawesome/fontawesome-free/scss/regular.scss';
|
|
||||||
@import '@fortawesome/fontawesome-free/scss/solid.scss';
|
|
||||||
@import '@fortawesome/fontawesome-free/scss/brands.scss';
|
|
||||||
|
|
@ -1,28 +1,28 @@
|
||||||
type AlertType = 'success' | 'info' | 'warning' | 'danger';
|
import React from 'react'
|
||||||
|
|
||||||
|
type AlertType = 'success' | 'info' | 'warning' | 'danger'
|
||||||
|
|
||||||
const icons = new Map<AlertType, string>([
|
const icons = new Map<AlertType, string>([
|
||||||
['success', 'check'],
|
['success', 'check'],
|
||||||
['info', 'info'],
|
['info', 'info'],
|
||||||
['warning', 'exclamation-triangle'],
|
['warning', 'exclamation-triangle'],
|
||||||
['danger', 'times-circle'],
|
['danger', 'times-circle'],
|
||||||
]);
|
])
|
||||||
|
|
||||||
type Props = {
|
interface Props {
|
||||||
readonly type: AlertType;
|
type: AlertType
|
||||||
readonly children?: React.ReactNode;
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const Alert: React.FC<Props> = ({type, children}) => {
|
const Alert: React.FC<Props> = (props) => {
|
||||||
const icon = icons.get(type);
|
const { type } = props
|
||||||
|
const icon = icons.get(type)
|
||||||
|
|
||||||
return children === ''
|
return props.children ? (
|
||||||
? null
|
<div className={`alert alert-${type}`}>
|
||||||
: (
|
<i className={`icon fas fa-${icon}`}></i>
|
||||||
<div className={`alert alert-${type}`}>
|
{props.children}
|
||||||
<i className={`icon fas fa-${icon}`}/>
|
</div>
|
||||||
{children}
|
) : null
|
||||||
</div>
|
}
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Alert;
|
export default Alert
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
type Props = {
|
import React from 'react'
|
||||||
readonly title?: string;
|
|
||||||
readonly onClick: React.MouseEventHandler<HTMLAnchorElement>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const ButtonEdit: React.FC<Props> = ({title, onClick}) => (
|
interface Props {
|
||||||
<a href='#' title={title} className='ml-2' onClick={onClick}>
|
title?: string
|
||||||
<i className='fas fa-edit'/>
|
onClick: React.MouseEventHandler<HTMLAnchorElement>
|
||||||
</a>
|
}
|
||||||
);
|
|
||||||
|
|
||||||
export default ButtonEdit;
|
const ButtonEdit: React.FC<Props> = (props) => (
|
||||||
|
<a href="#" title={props.title} className="ml-2" onClick={props.onClick}>
|
||||||
|
<i className="fas fa-edit"></i>
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
|
||||||
|
export default ButtonEdit
|
||||||
|
|
|
||||||
|
|
@ -1,108 +1,103 @@
|
||||||
import {emit, on} from '@/scripts/event';
|
/** @jsxImportSource @emotion/react */
|
||||||
import {t} from '@/scripts/i18n';
|
import * as React from 'react'
|
||||||
import * as cssUtils from '@/styles/utils';
|
import Reaptcha from 'reaptcha'
|
||||||
import React from 'react';
|
import { emit, on } from '@/scripts/event'
|
||||||
import Reaptcha from 'reaptcha';
|
import { t } from '@/scripts/i18n'
|
||||||
|
import * as cssUtils from '@/styles/utils'
|
||||||
|
|
||||||
const eventId = Symbol('EventId');
|
const eventId = Symbol()
|
||||||
|
|
||||||
type State = {
|
type State = {
|
||||||
value: string;
|
value: string
|
||||||
time: number;
|
time: number
|
||||||
sitekey: string;
|
sitekey: string
|
||||||
invisible: boolean;
|
invisible: boolean
|
||||||
};
|
|
||||||
|
|
||||||
class Captcha extends React.Component<Record<string, unknown>, State> {
|
|
||||||
state: State;
|
|
||||||
// eslint-disable-next-line ts/no-restricted-types
|
|
||||||
ref: React.RefObject<Reaptcha | null>;
|
|
||||||
|
|
||||||
constructor(props: Record<string, unknown>) {
|
|
||||||
super(props);
|
|
||||||
this.state = {
|
|
||||||
value: '',
|
|
||||||
time: Date.now(),
|
|
||||||
sitekey: blessing.extra.recaptcha as string,
|
|
||||||
invisible: blessing.extra.invisible as boolean,
|
|
||||||
};
|
|
||||||
this.ref = React.createRef<Reaptcha>();
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line react/no-unused-class-component-members
|
|
||||||
async execute() {
|
|
||||||
const recaptcha = this.ref.current;
|
|
||||||
if (recaptcha && this.state.invisible) {
|
|
||||||
return new Promise<string>(resolve => {
|
|
||||||
const off = on(eventId, (value: string) => {
|
|
||||||
resolve(value);
|
|
||||||
off();
|
|
||||||
});
|
|
||||||
void recaptcha.execute();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.state.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line react/no-unused-class-component-members
|
|
||||||
reset() {
|
|
||||||
const recaptcha = this.ref.current;
|
|
||||||
if (recaptcha) {
|
|
||||||
void recaptcha.reset();
|
|
||||||
} else {
|
|
||||||
this.setState({time: Date.now()});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleValueChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
this.setState({value: event.target.value});
|
|
||||||
};
|
|
||||||
|
|
||||||
handleVerify = (value: string) => {
|
|
||||||
emit(eventId, value);
|
|
||||||
this.setState({value});
|
|
||||||
};
|
|
||||||
|
|
||||||
handleRefresh = () => {
|
|
||||||
this.setState({time: Date.now()});
|
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
|
||||||
return this.state.sitekey
|
|
||||||
? (
|
|
||||||
<div className='mb-2'>
|
|
||||||
<Reaptcha
|
|
||||||
ref={this.ref}
|
|
||||||
sitekey={this.state.sitekey}
|
|
||||||
size={this.state.invisible ? 'invisible' : 'normal'}
|
|
||||||
onVerify={this.handleVerify}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
: (
|
|
||||||
<div className='d-flex'>
|
|
||||||
<div className='form-group mb-3 mr-2'>
|
|
||||||
<input
|
|
||||||
required
|
|
||||||
type='text'
|
|
||||||
className='form-control'
|
|
||||||
placeholder={t('auth.captcha')}
|
|
||||||
value={this.state.value}
|
|
||||||
onChange={this.handleValueChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<img
|
|
||||||
src={`${blessing.base_url}/auth/captcha?v=${this.state.time}`}
|
|
||||||
alt={t('auth.captcha')}
|
|
||||||
css={cssUtils.pointerCursor}
|
|
||||||
height={34}
|
|
||||||
title={t('auth.change-captcha')}
|
|
||||||
onClick={this.handleRefresh}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Captcha;
|
class Captcha extends React.Component<Record<string, unknown>, State> {
|
||||||
|
state: State
|
||||||
|
ref: React.MutableRefObject<Reaptcha | null>
|
||||||
|
|
||||||
|
constructor(props: Record<string, unknown>) {
|
||||||
|
super(props)
|
||||||
|
this.state = {
|
||||||
|
value: '',
|
||||||
|
time: Date.now(),
|
||||||
|
sitekey: blessing.extra.recaptcha,
|
||||||
|
invisible: blessing.extra.invisible,
|
||||||
|
}
|
||||||
|
this.ref = React.createRef()
|
||||||
|
}
|
||||||
|
|
||||||
|
execute = async () => {
|
||||||
|
const recaptcha = this.ref.current
|
||||||
|
if (recaptcha && this.state.invisible) {
|
||||||
|
return new Promise<string>((resolve) => {
|
||||||
|
const off = on(eventId, (value: string) => {
|
||||||
|
resolve(value)
|
||||||
|
off()
|
||||||
|
})
|
||||||
|
recaptcha.execute()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return this.state.value
|
||||||
|
}
|
||||||
|
|
||||||
|
reset = () => {
|
||||||
|
const recaptcha = this.ref.current
|
||||||
|
if (recaptcha) {
|
||||||
|
recaptcha.reset()
|
||||||
|
} else {
|
||||||
|
this.setState({ time: Date.now() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleValueChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
this.setState({ value: event.target.value })
|
||||||
|
}
|
||||||
|
|
||||||
|
handleVerify = (value: string) => {
|
||||||
|
emit(eventId, value)
|
||||||
|
this.setState({ value })
|
||||||
|
}
|
||||||
|
|
||||||
|
handleRefresh = () => {
|
||||||
|
this.setState({ time: Date.now() })
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return this.state.sitekey ? (
|
||||||
|
<div className="mb-2">
|
||||||
|
<Reaptcha
|
||||||
|
ref={this.ref}
|
||||||
|
sitekey={this.state.sitekey}
|
||||||
|
size={this.state.invisible ? 'invisible' : 'normal'}
|
||||||
|
onVerify={this.handleVerify}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="d-flex">
|
||||||
|
<div className="form-group mb-3 mr-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
placeholder={t('auth.captcha')}
|
||||||
|
required
|
||||||
|
value={this.state.value}
|
||||||
|
onChange={this.handleValueChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<img
|
||||||
|
src={`${blessing.base_url}/auth/captcha?v=${this.state.time}`}
|
||||||
|
alt={t('auth.captcha')}
|
||||||
|
css={cssUtils.pointerCursor}
|
||||||
|
height={34}
|
||||||
|
title={t('auth.change-captcha')}
|
||||||
|
onClick={this.handleRefresh}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Captcha
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,27 @@
|
||||||
import * as fetch from '@/scripts/net';
|
import React, { useState } from 'react'
|
||||||
import {useState} from 'react';
|
import * as fetch from '@/scripts/net'
|
||||||
|
|
||||||
type Props = {
|
interface Props {
|
||||||
readonly initMode: boolean;
|
initMode: boolean
|
||||||
};
|
}
|
||||||
|
|
||||||
const DarkModeButton: React.FC<Props> = ({initMode}) => {
|
const DarkModeButton: React.FC<Props> = ({ initMode }) => {
|
||||||
const [darkMode, setDarkMode] = useState(initMode);
|
const [darkMode, setDarkMode] = useState(initMode)
|
||||||
|
|
||||||
const icon = darkMode ? 'moon' : 'sun';
|
const icon = darkMode ? 'moon' : 'sun'
|
||||||
|
|
||||||
const handleClick = async () => {
|
const handleClick = async () => {
|
||||||
setDarkMode(value => !value);
|
setDarkMode((value) => !value)
|
||||||
|
|
||||||
await fetch.put('/user/dark-mode');
|
await fetch.put('/user/dark-mode')
|
||||||
document.body.classList.toggle('dark-mode');
|
document.body.classList.toggle('dark-mode')
|
||||||
};
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a className='nav-link' href='#' role='button' onClick={handleClick}>
|
<a className="nav-link" href="#" role="button" onClick={handleClick}>
|
||||||
<i className={`fas fa-${icon}`}/>
|
<i className={`fas fa-${icon}`}></i>
|
||||||
</a>
|
</a>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|
||||||
export default DarkModeButton;
|
export default DarkModeButton
|
||||||
|
|
|
||||||
|
|
@ -1,67 +1,89 @@
|
||||||
import {emit} from '@/scripts/event';
|
/** @jsxImportSource @emotion/react */
|
||||||
import {pointerCursor} from '@/styles/utils';
|
import React, { useState, useEffect } from 'react'
|
||||||
import {css} from '@emotion/react';
|
import Autosuggest from 'react-autosuggest'
|
||||||
import clsx from 'clsx';
|
import { css } from '@emotion/react'
|
||||||
import {useCombobox} from 'downshift';
|
import { emit } from '@/scripts/event'
|
||||||
import {useEffect, useState} from 'react';
|
import { pointerCursor } from '@/styles/utils'
|
||||||
|
|
||||||
const styles = css`
|
const styles = css`
|
||||||
.dropdown-menu li {
|
.dropdown-menu li {
|
||||||
${pointerCursor}
|
${pointerCursor}
|
||||||
}
|
}
|
||||||
`;
|
`
|
||||||
|
|
||||||
const domainNames = new Set(['qq.com', '163.com', 'gmail.com', 'hotmail.com']);
|
const domainNames = new Set(['qq.com', '163.com', 'gmail.com', 'hotmail.com'])
|
||||||
|
|
||||||
type Props = Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange'> & {
|
type Props = Omit<Autosuggest.InputProps<string>, 'onChange'> & {
|
||||||
onChange: (value: string) => void;
|
onChange(value: string): void
|
||||||
};
|
}
|
||||||
|
|
||||||
const EmailSuggestion: React.FC<Props> = props => {
|
const EmailSuggestion: React.FC<Props> = (props) => {
|
||||||
useEffect(() => {
|
const [suggestions, setSuggestions] = useState<string[]>([])
|
||||||
emit('emailDomainsSuggestion', domainNames);
|
|
||||||
}, []);
|
|
||||||
const [inputItems, setInputItems] = useState<string[]>([]);
|
|
||||||
|
|
||||||
const {
|
useEffect(() => {
|
||||||
isOpen,
|
emit('emailDomainsSuggestion', domainNames)
|
||||||
getLabelProps,
|
}, [])
|
||||||
getMenuProps,
|
|
||||||
getInputProps,
|
|
||||||
highlightedIndex,
|
|
||||||
getItemProps,
|
|
||||||
} = useCombobox({
|
|
||||||
items: inputItems,
|
|
||||||
onInputValueChange({inputValue: value}) {
|
|
||||||
setInputItems([...domainNames].map(name => `${value.split('@')[0]}@${name}`));
|
|
||||||
if (value.length === 0 || value.includes('@')) {
|
|
||||||
setInputItems([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const {onChange} = props;
|
const handleSuggestionsFetchRequested: Autosuggest.SuggestionsFetchRequested =
|
||||||
onChange(value);
|
({ value }) => {
|
||||||
},
|
const segments = value.split('@')
|
||||||
});
|
setSuggestions([...domainNames].map((name) => `${segments[0]}@${name}`))
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
const handleSuggestionsClearRequested = () => {
|
||||||
<div>
|
setSuggestions([])
|
||||||
<div className='input-group'>
|
}
|
||||||
<input className='form-control' {...{...props, onChange: undefined}} {...getInputProps()}/>
|
|
||||||
<div className='input-group-text' {...getLabelProps()}>
|
|
||||||
<i className='fas fa-envelope'/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className='mb-3 dropdown' css={styles}>
|
|
||||||
<ul className={clsx('dropdown-menu', isOpen && inputItems.length > 0 && 'show')} {...getMenuProps()}>
|
|
||||||
{isOpen && inputItems.length > 0 && inputItems.map((item, index) => (
|
|
||||||
<li key={`${item}`} className={clsx('dropdown-item', {active: index === highlightedIndex})} {...getItemProps({item, index})}>
|
|
||||||
{item}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default EmailSuggestion;
|
const shouldRenderSuggestions = (value: string) => {
|
||||||
|
const isSelecting = [...domainNames].some((name) =>
|
||||||
|
value.endsWith(`@${name}`),
|
||||||
|
)
|
||||||
|
|
||||||
|
return isSelecting || (value.length > 0 && !value.includes('@'))
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSuggestionValue = (value: string) => value
|
||||||
|
|
||||||
|
const renderSuggestion = (suggestion: string) => suggestion
|
||||||
|
|
||||||
|
const handleChange = (_: React.FormEvent, event: Autosuggest.ChangeEvent) => {
|
||||||
|
props.onChange(event.newValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderInputComponent = (
|
||||||
|
props: Omit<Autosuggest.InputProps<string>, 'onChange'>,
|
||||||
|
) => (
|
||||||
|
<div className="input-group">
|
||||||
|
<input className="form-control" {...props} />
|
||||||
|
<div className="input-group-append">
|
||||||
|
<div className="input-group-text">
|
||||||
|
<i className="fas fa-envelope"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div css={styles}>
|
||||||
|
<Autosuggest
|
||||||
|
suggestions={suggestions}
|
||||||
|
onSuggestionsFetchRequested={handleSuggestionsFetchRequested}
|
||||||
|
onSuggestionsClearRequested={handleSuggestionsClearRequested}
|
||||||
|
getSuggestionValue={getSuggestionValue}
|
||||||
|
renderSuggestion={renderSuggestion}
|
||||||
|
shouldRenderSuggestions={shouldRenderSuggestions}
|
||||||
|
inputProps={Object.assign({}, props, { onChange: handleChange })}
|
||||||
|
renderInputComponent={renderInputComponent}
|
||||||
|
theme={{
|
||||||
|
container: 'mb-3',
|
||||||
|
suggestion: 'dropdown-item',
|
||||||
|
suggestionsContainer: 'dropdown',
|
||||||
|
suggestionsList: `dropdown-menu ${suggestions.length ? 'show' : ''}`,
|
||||||
|
suggestionHighlighted: 'active',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EmailSuggestion
|
||||||
|
|
|
||||||
|
|
@ -1,52 +1,53 @@
|
||||||
import {t} from '@/scripts/i18n';
|
/** @jsxImportSource @emotion/react */
|
||||||
import {css} from '@emotion/react';
|
import { useRef } from 'react'
|
||||||
import {useRef} from 'react';
|
import { css } from '@emotion/react'
|
||||||
|
import { t } from '@/scripts/i18n'
|
||||||
|
|
||||||
const hideRawBrowseButton = css`
|
const hideRawBrowseButton = css`
|
||||||
::after {
|
::after {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
`;
|
`
|
||||||
|
|
||||||
type Props = {
|
interface Props {
|
||||||
file: File | undefined;
|
file: File | null
|
||||||
accept?: string;
|
accept?: string
|
||||||
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
onChange(event: React.ChangeEvent<HTMLInputElement>): void
|
||||||
};
|
}
|
||||||
|
|
||||||
const FileInput: React.FC<Props> = props => {
|
const FileInput: React.FC<Props> = (props) => {
|
||||||
const reference = useRef<HTMLInputElement>(null);
|
const ref = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
reference.current!.click();
|
ref.current!.click()
|
||||||
};
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='form-group'>
|
<div className="form-group">
|
||||||
<label htmlFor='select-file'>{t('skinlib.upload.select-file')}</label>
|
<label htmlFor="select-file">{t('skinlib.upload.select-file')}</label>
|
||||||
<div className='input-group'>
|
<div className="input-group">
|
||||||
<div className='custom-file'>
|
<div className="custom-file">
|
||||||
<input
|
<input
|
||||||
ref={reference}
|
type="file"
|
||||||
type='file'
|
className="custom-file-input"
|
||||||
className='custom-file-input'
|
id="select-file"
|
||||||
id='select-file'
|
accept={props.accept}
|
||||||
accept={props.accept}
|
title={t('skinlib.upload.select-file')}
|
||||||
title={t('skinlib.upload.select-file')}
|
ref={ref}
|
||||||
onChange={props.onChange}
|
onChange={props.onChange}
|
||||||
/>
|
/>
|
||||||
<label className='custom-file-label' css={hideRawBrowseButton}>
|
<label className="custom-file-label" css={hideRawBrowseButton}>
|
||||||
{props.file?.name}
|
{props.file?.name}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div className='input-group-append'>
|
<div className="input-group-append">
|
||||||
<button className='btn btn-default' onClick={handleClick}>
|
<button className="btn btn-default" onClick={handleClick}>
|
||||||
{t('skinlib.upload.select-file')}
|
{t('skinlib.upload.select-file')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|
||||||
export default FileInput;
|
export default FileInput
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
function Loading() {
|
import React from 'react'
|
||||||
return (
|
|
||||||
<div className='container text-center' title='Loading...'>
|
|
||||||
<i className='fas fa-sync fa-spin'/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Loading;
|
const Loading = () => (
|
||||||
|
<div className="container text-center" title="Loading...">
|
||||||
|
<i className="fas fa-sync fa-spin"></i>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
export default Loading
|
||||||
|
|
|
||||||
|
|
@ -1,193 +1,165 @@
|
||||||
import {Modal as BootstrapModal} from 'bootstrap';
|
import React, { useState, useEffect, useRef } from 'react'
|
||||||
import clsx from 'clsx';
|
import $ from 'jquery'
|
||||||
import {useEffect, useRef, useState} from 'react';
|
import 'bootstrap'
|
||||||
import {t} from '../scripts/i18n';
|
import { t } from '../scripts/i18n'
|
||||||
import ModalBody, {type Props as BodyProps} from './ModalBody';
|
import ModalHeader from './ModalHeader'
|
||||||
import ModalFooter, {type Props as FooterProps} from './ModalFooter';
|
import ModalBody from './ModalBody'
|
||||||
import ModalHeader, {type Props as HeaderProps} from './ModalHeader';
|
import ModalFooter from './ModalFooter'
|
||||||
|
import type { Props as HeaderProps } from './ModalHeader'
|
||||||
|
import type { Props as BodyProps } from './ModalBody'
|
||||||
|
import type { Props as FooterProps } from './ModalFooter'
|
||||||
|
|
||||||
type BasicOptions = {
|
type BasicOptions = {
|
||||||
readonly mode?: 'alert' | 'confirm' | 'prompt';
|
mode?: 'alert' | 'confirm' | 'prompt'
|
||||||
readonly show?: boolean;
|
show?: boolean
|
||||||
readonly input?: string;
|
input?: string
|
||||||
validator?: (value: any) => string | boolean | undefined;
|
validator?(value: any): string | boolean | undefined
|
||||||
readonly type?: string;
|
type?: string
|
||||||
readonly showHeader?: boolean;
|
showHeader?: boolean
|
||||||
readonly center?: boolean;
|
center?: boolean
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode
|
||||||
};
|
}
|
||||||
|
|
||||||
export type ModalOptions = BasicOptions & HeaderProps & BodyProps & FooterProps;
|
export type ModalOptions = BasicOptions & HeaderProps & BodyProps & FooterProps
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
readonly id?: string;
|
id?: string
|
||||||
readonly children?: React.ReactNode;
|
children?: React.ReactNode
|
||||||
readonly footer?: React.ReactNode;
|
footer?: React.ReactNode
|
||||||
onConfirm?: (payload: {value: string}) => void;
|
onConfirm?(payload: { value: string }): void
|
||||||
onDismiss?: () => void;
|
onDismiss?(): void
|
||||||
onClose?: () => void;
|
onClose?(): void
|
||||||
};
|
}
|
||||||
|
|
||||||
export type ModalResult = {
|
export type ModalResult = {
|
||||||
value: string;
|
value: string
|
||||||
};
|
}
|
||||||
|
|
||||||
const Modal: React.FC<ModalOptions & Props> = props => {
|
const Modal: React.FC<ModalOptions & Props> = (props) => {
|
||||||
const {
|
const {
|
||||||
mode = 'confirm',
|
mode = 'confirm',
|
||||||
title = t('general.tip'),
|
title = t('general.tip'),
|
||||||
text = '',
|
text = '',
|
||||||
input = '',
|
input = '',
|
||||||
placeholder = '',
|
placeholder = '',
|
||||||
inputType = 'text',
|
inputType = 'text',
|
||||||
inputMode,
|
inputMode,
|
||||||
type = 'default',
|
type = 'default',
|
||||||
showHeader = true,
|
showHeader = true,
|
||||||
center = false,
|
center = false,
|
||||||
okButtonText = t('general.confirm'),
|
okButtonText = t('general.confirm'),
|
||||||
okButtonType = 'primary',
|
okButtonType = 'primary',
|
||||||
cancelButtonText = t('general.cancel'),
|
cancelButtonText = t('general.cancel'),
|
||||||
cancelButtonType = 'secondary',
|
cancelButtonType = 'secondary',
|
||||||
flexFooter = false,
|
flexFooter = false,
|
||||||
footer,
|
} = props
|
||||||
show,
|
|
||||||
onClose,
|
|
||||||
onDismiss,
|
|
||||||
id,
|
|
||||||
validator,
|
|
||||||
onConfirm,
|
|
||||||
children,
|
|
||||||
choices,
|
|
||||||
dangerousHTML: html,
|
|
||||||
} = props;
|
|
||||||
|
|
||||||
const [value, setValue] = useState(input);
|
const [value, setValue] = useState(input)
|
||||||
const [valid, setValid] = useState(true);
|
const [valid, setValid] = useState(true)
|
||||||
const [validatorMessage, setValidatorMessage] = useState('');
|
const [validatorMessage, setValidatorMessage] = useState('')
|
||||||
const reference = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
const [modal, setModal] = useState<BootstrapModal>();
|
|
||||||
|
|
||||||
useEffect(() => {
|
const { show } = props
|
||||||
if (!reference.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const _modal = new BootstrapModal(reference.current);
|
useEffect(() => {
|
||||||
setModal(_modal);
|
if (!show) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
return () => {
|
const onHidden = () => props.onClose?.()
|
||||||
_modal.dispose();
|
|
||||||
};
|
|
||||||
}, [reference]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const el = $(ref.current!)
|
||||||
if (!show) {
|
el.on('hidden.bs.modal', onHidden)
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const onHidden = () => {
|
return () => {
|
||||||
onClose?.();
|
el.off('hidden.bs.modal', onHidden)
|
||||||
};
|
}
|
||||||
|
}, [show, props.onClose])
|
||||||
|
|
||||||
const element = reference.current;
|
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (!element) {
|
setValue(event.target.value)
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
element.addEventListener('hidden.bs.modal', onHidden);
|
const confirm = () => {
|
||||||
|
const { validator } = props
|
||||||
|
if (typeof validator === 'function') {
|
||||||
|
const result = validator(value)
|
||||||
|
if (typeof result === 'string') {
|
||||||
|
setValidatorMessage(result)
|
||||||
|
setValid(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return () => {
|
props.onConfirm?.({ value })
|
||||||
element.removeEventListener('hidden.bs.modal', onHidden);
|
$(ref.current!).modal('hide')
|
||||||
};
|
|
||||||
}, [reference, show, onClose]);
|
|
||||||
|
|
||||||
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
// The "hidden.bs.modal" event can't be trigged automatically when testing.
|
||||||
setValue(event.target.value);
|
/* istanbul ignore next */
|
||||||
};
|
if (process.env.NODE_ENV === 'test') {
|
||||||
|
$(ref.current!).trigger('hidden.bs.modal')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const confirm = () => {
|
const dismiss = () => {
|
||||||
if (typeof validator === 'function') {
|
props.onDismiss?.()
|
||||||
const result = validator(value);
|
$(ref.current!).modal('hide')
|
||||||
if (typeof result === 'string') {
|
|
||||||
setValidatorMessage(result);
|
|
||||||
setValid(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onConfirm?.({value});
|
/* istanbul ignore next */
|
||||||
modal?.hide();
|
if (process.env.NODE_ENV === 'test') {
|
||||||
|
$(ref.current!).trigger('hidden.bs.modal')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// The "hidden.bs.modal" event can't be trigged automatically when testing.
|
useEffect(() => {
|
||||||
|
if (show) {
|
||||||
|
setTimeout(() => $(ref.current!).modal('show'), 50)
|
||||||
|
}
|
||||||
|
}, [show])
|
||||||
|
|
||||||
if (import.meta.env.NODE_ENV === 'test') {
|
if (!show) {
|
||||||
$(reference.current!).trigger('hidden.bs.modal');
|
return null
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const dismiss = () => {
|
return (
|
||||||
onDismiss?.();
|
<div id={props.id} className="modal fade" role="dialog" ref={ref}>
|
||||||
modal?.hide();
|
<div
|
||||||
|
className={`modal-dialog ${center ? 'modal-dialog-centered' : ''}`}
|
||||||
|
role="document"
|
||||||
|
>
|
||||||
|
<div className={`modal-content bg-${type}`}>
|
||||||
|
<ModalHeader show={showHeader} title={title} onDismiss={dismiss} />
|
||||||
|
<ModalBody
|
||||||
|
text={text}
|
||||||
|
dangerousHTML={props.dangerousHTML}
|
||||||
|
showInput={mode === 'prompt'}
|
||||||
|
value={value}
|
||||||
|
choices={props.choices}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
inputType={inputType}
|
||||||
|
inputMode={inputMode}
|
||||||
|
placeholder={placeholder}
|
||||||
|
invalid={!valid}
|
||||||
|
validatorMessage={validatorMessage}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter
|
||||||
|
showCancelButton={mode !== 'alert'}
|
||||||
|
flexFooter={flexFooter}
|
||||||
|
okButtonType={okButtonType}
|
||||||
|
okButtonText={okButtonText}
|
||||||
|
cancelButtonType={cancelButtonType}
|
||||||
|
cancelButtonText={cancelButtonText}
|
||||||
|
onConfirm={confirm}
|
||||||
|
onDismiss={dismiss}
|
||||||
|
>
|
||||||
|
{props.footer}
|
||||||
|
</ModalFooter>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (import.meta.env.NODE_ENV === 'test') {
|
export default Modal
|
||||||
$(reference.current!).trigger('hidden.bs.modal');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (show && modal) {
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
modal.show();
|
|
||||||
}, 50);
|
|
||||||
return () => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [show, modal]);
|
|
||||||
|
|
||||||
if (!show) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={reference} id={id} className='modal fade' role='dialog'>
|
|
||||||
<div
|
|
||||||
className={clsx('modal-dialog', center && 'modal-dialog-centered')}
|
|
||||||
role='document'
|
|
||||||
>
|
|
||||||
<div className={`modal-content bg-${type}`}>
|
|
||||||
<ModalHeader show={showHeader} title={title} onDismiss={dismiss}/>
|
|
||||||
<ModalBody
|
|
||||||
text={text}
|
|
||||||
dangerousHTML={html}
|
|
||||||
showInput={mode === 'prompt'}
|
|
||||||
value={value}
|
|
||||||
choices={choices}
|
|
||||||
inputType={inputType}
|
|
||||||
inputMode={inputMode}
|
|
||||||
placeholder={placeholder}
|
|
||||||
invalid={!valid}
|
|
||||||
validatorMessage={validatorMessage}
|
|
||||||
onChange={handleInputChange}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</ModalBody>
|
|
||||||
<ModalFooter
|
|
||||||
showCancelButton={mode !== 'alert'}
|
|
||||||
flexFooter={flexFooter}
|
|
||||||
okButtonType={okButtonType}
|
|
||||||
okButtonText={okButtonText}
|
|
||||||
cancelButtonType={cancelButtonType}
|
|
||||||
cancelButtonText={cancelButtonText}
|
|
||||||
onConfirm={confirm}
|
|
||||||
onDismiss={dismiss}
|
|
||||||
>
|
|
||||||
{footer}
|
|
||||||
</ModalFooter>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Modal;
|
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,29 @@
|
||||||
|
import React from 'react'
|
||||||
|
import ModalContent from './ModalContent'
|
||||||
|
import ModalInput from './ModalInput'
|
||||||
|
import type { Props as ContentProps } from './ModalContent'
|
||||||
|
import type {
|
||||||
|
Props as InputProps,
|
||||||
|
InternalProps as InputInteralProps,
|
||||||
|
} from './ModalInput'
|
||||||
|
|
||||||
import ModalContent, {type Props as ContentProps} from './ModalContent';
|
interface InternalProps {
|
||||||
import ModalInput, {
|
showInput: boolean
|
||||||
type
|
}
|
||||||
InternalProps as InputInteralProps,
|
|
||||||
type
|
|
||||||
Props as InputProps,
|
|
||||||
} from './ModalInput';
|
|
||||||
|
|
||||||
type InternalProps = {
|
export type Props = ContentProps & InputProps
|
||||||
readonly showInput: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Props = ContentProps & InputProps;
|
const ModalBody: React.FC<InternalProps & InputInteralProps & Props> = (
|
||||||
|
props,
|
||||||
|
) => {
|
||||||
|
return (
|
||||||
|
<div className="modal-body">
|
||||||
|
<ModalContent text={props.text} dangerousHTML={props.dangerousHTML}>
|
||||||
|
{props.children}
|
||||||
|
</ModalContent>
|
||||||
|
{props.showInput && <ModalInput {...props} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const ModalBody: React.FC<InternalProps & InputInteralProps & Props> = props => (
|
export default ModalBody
|
||||||
<div className='modal-body'>
|
|
||||||
<ModalContent text={props.text} dangerousHTML={props.dangerousHTML}>
|
|
||||||
{props.children}
|
|
||||||
</ModalContent>
|
|
||||||
{props.showInput && <ModalInput {...props}/>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default ModalBody;
|
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,26 @@
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
export type Props = {
|
export interface Props {
|
||||||
readonly text?: string;
|
text?: string
|
||||||
readonly dangerousHTML?: string;
|
dangerousHTML?: string
|
||||||
readonly children?: React.ReactNode;
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const ModalContent: React.FC<Props> = props => {
|
const ModalContent: React.FC<Props> = (props) => {
|
||||||
if (props.children) {
|
if (props.children) {
|
||||||
return <>{props.children}</>;
|
return <>{props.children}</>
|
||||||
}
|
} else if (props.text) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{props.text.split(/\r?\n/).map((line, i) => (
|
||||||
|
<p key={i}>{line}</p>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
} else if (props.dangerousHTML) {
|
||||||
|
return <div dangerouslySetInnerHTML={{ __html: props.dangerousHTML }} />
|
||||||
|
}
|
||||||
|
|
||||||
if (props.text) {
|
return <></>
|
||||||
return (
|
}
|
||||||
<>
|
|
||||||
{props.text.split(/\r?\n/).map((line, i) =>
|
|
||||||
<p key={i}>{line}</p>)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (props.dangerousHTML) {
|
export default ModalContent
|
||||||
return <div dangerouslySetInnerHTML={{__html: props.dangerousHTML}}/>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <></>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ModalContent;
|
|
||||||
|
|
|
||||||
|
|
@ -1,50 +1,49 @@
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
export type Props = {
|
export interface Props {
|
||||||
readonly flexFooter?: boolean;
|
flexFooter?: boolean
|
||||||
readonly okButtonText?: string;
|
okButtonText?: string
|
||||||
readonly okButtonType?: string;
|
okButtonType?: string
|
||||||
readonly cancelButtonText?: string;
|
cancelButtonText?: string
|
||||||
readonly cancelButtonType?: string;
|
cancelButtonType?: string
|
||||||
readonly children?: React.ReactNode;
|
}
|
||||||
};
|
|
||||||
|
|
||||||
type InternalProps = {
|
interface InternalProps {
|
||||||
readonly showCancelButton: boolean;
|
showCancelButton: boolean
|
||||||
onConfirm?: () => void;
|
onConfirm?(): void
|
||||||
onDismiss?: () => void;
|
onDismiss?(): void
|
||||||
};
|
}
|
||||||
|
|
||||||
const ModalFooter: React.FC<InternalProps & Props> = props => {
|
const ModalFooter: React.FC<InternalProps & Props> = (props) => {
|
||||||
const classes = ['modal-footer'];
|
const classes = ['modal-footer']
|
||||||
if (props.flexFooter) {
|
if (props.flexFooter) {
|
||||||
classes.push('d-flex', 'justify-content-between');
|
classes.push('d-flex', 'justify-content-between')
|
||||||
}
|
}
|
||||||
|
const footerClass = classes.join(' ')
|
||||||
|
|
||||||
const footerClass = classes.join(' ');
|
return props.children ? (
|
||||||
|
<div className={footerClass}>{props.children}</div>
|
||||||
|
) : (
|
||||||
|
<div className={footerClass}>
|
||||||
|
{props.showCancelButton && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`btn btn-${props.cancelButtonType}`}
|
||||||
|
data-dismiss="modal"
|
||||||
|
onClick={props.onDismiss}
|
||||||
|
>
|
||||||
|
{props.cancelButtonText}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`btn btn-${props.okButtonType}`}
|
||||||
|
onClick={props.onConfirm}
|
||||||
|
>
|
||||||
|
{props.okButtonText}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return props.children
|
export default ModalFooter
|
||||||
? <div className={footerClass}>{props.children}</div>
|
|
||||||
: (
|
|
||||||
<div className={footerClass}>
|
|
||||||
{props.showCancelButton && (
|
|
||||||
<button
|
|
||||||
type='button'
|
|
||||||
className={`btn btn-${props.cancelButtonType}`}
|
|
||||||
data-dismiss='modal'
|
|
||||||
onClick={props.onDismiss}
|
|
||||||
>
|
|
||||||
{props.cancelButtonText}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type='button'
|
|
||||||
className={`btn btn-${props.okButtonType}`}
|
|
||||||
onClick={props.onConfirm}
|
|
||||||
>
|
|
||||||
{props.okButtonText}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ModalFooter;
|
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,28 @@
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
export type Props = {
|
export interface Props {
|
||||||
readonly title?: string;
|
title?: string
|
||||||
};
|
}
|
||||||
|
|
||||||
type InternalProps = {
|
interface InternalProps {
|
||||||
onDismiss?: () => void;
|
onDismiss?(): void
|
||||||
readonly show?: boolean;
|
show?: boolean
|
||||||
};
|
}
|
||||||
|
|
||||||
const ModalHeader: React.FC<Props & InternalProps> = ({show, title, onDismiss}) =>
|
const ModalHeader: React.FC<Props & InternalProps> = (props) =>
|
||||||
show
|
props.show ? (
|
||||||
? (
|
<div className="modal-header">
|
||||||
<div className='modal-header'>
|
<h5 className="modal-title">{props.title}</h5>
|
||||||
<h5 className='modal-title'>{title}</h5>
|
<button
|
||||||
<button
|
type="button"
|
||||||
type='button'
|
className="close"
|
||||||
className='btn-close'
|
data-dismiss="modal"
|
||||||
data-bs-dismiss='modal'
|
aria-label="Close"
|
||||||
aria-label='Close'
|
onClick={props.onDismiss}
|
||||||
onClick={onDismiss}
|
>
|
||||||
/>
|
<span aria-hidden>×</span>
|
||||||
</div>
|
</button>
|
||||||
)
|
</div>
|
||||||
: null;
|
) : null
|
||||||
|
|
||||||
export default ModalHeader;
|
export default ModalHeader
|
||||||
|
|
|
||||||
|
|
@ -1,59 +1,58 @@
|
||||||
|
import React, { HTMLAttributes } from 'react'
|
||||||
|
|
||||||
export type Props = {
|
export interface Props {
|
||||||
readonly inputType?: string;
|
inputType?: string
|
||||||
readonly inputMode?: React.HTMLAttributes<HTMLInputElement>['inputMode'];
|
inputMode?: HTMLAttributes<HTMLInputElement>['inputMode']
|
||||||
readonly choices?: Array<{text: string; value: string}>;
|
choices?: { text: string; value: string }[]
|
||||||
readonly placeholder?: string;
|
placeholder?: string
|
||||||
};
|
}
|
||||||
|
|
||||||
export type InternalProps = {
|
export interface InternalProps {
|
||||||
readonly value?: string;
|
value?: string
|
||||||
readonly invalid?: boolean;
|
invalid?: boolean
|
||||||
readonly validatorMessage?: string;
|
validatorMessage?: string
|
||||||
readonly onChange?: React.ChangeEventHandler<HTMLInputElement>;
|
onChange?: React.ChangeEventHandler<HTMLInputElement>
|
||||||
};
|
}
|
||||||
|
|
||||||
const ModalInput: React.FC<InternalProps & Props> = props => (
|
const ModalInput: React.FC<InternalProps & Props> = (props) => (
|
||||||
<>
|
<>
|
||||||
{props.inputType === 'radios' && props.choices
|
{props.inputType === 'radios' && props.choices ? (
|
||||||
? (
|
<>
|
||||||
<>
|
{props.choices.map((choice) => (
|
||||||
{props.choices.map(choice => (
|
<div key={choice.value}>
|
||||||
<div key={choice.value}>
|
<input
|
||||||
<input
|
type="radio"
|
||||||
type='radio'
|
name="modal-radios"
|
||||||
name='modal-radios'
|
id={`modal-radio-${choice.value}`}
|
||||||
id={`modal-radio-${choice.value}`}
|
value={choice.value}
|
||||||
value={choice.value}
|
checked={choice.value === props.value}
|
||||||
checked={choice.value === props.value}
|
onChange={props.onChange}
|
||||||
onChange={props.onChange}
|
/>
|
||||||
/>
|
<label htmlFor={`modal-radio-${choice.value}`} className="ml-1">
|
||||||
<label htmlFor={`modal-radio-${choice.value}`} className='ml-1'>
|
{choice.text}
|
||||||
{choice.text}
|
</label>
|
||||||
</label>
|
</div>
|
||||||
</div>
|
))}
|
||||||
))}
|
</>
|
||||||
</>
|
) : (
|
||||||
)
|
<div className="form-group">
|
||||||
: (
|
<input
|
||||||
<div className='form-group'>
|
value={props.value}
|
||||||
<input
|
onChange={props.onChange}
|
||||||
value={props.value}
|
type={props.inputType}
|
||||||
type={props.inputType}
|
inputMode={props.inputMode}
|
||||||
inputMode={props.inputMode}
|
className="form-control"
|
||||||
className='form-control'
|
placeholder={props.placeholder}
|
||||||
placeholder={props.placeholder}
|
></input>
|
||||||
onChange={props.onChange}
|
</div>
|
||||||
/>
|
)}
|
||||||
</div>
|
{props.invalid && (
|
||||||
)}
|
<div className="alert alert-danger">
|
||||||
{props.invalid && (
|
<i className="icon far fa-times-circle"></i>
|
||||||
<div className='alert alert-danger'>
|
<span className="ml-1">{props.validatorMessage}</span>
|
||||||
<i className='icon far fa-times-circle'/>
|
</div>
|
||||||
<span className='ml-1'>{props.validatorMessage}</span>
|
)}
|
||||||
</div>
|
</>
|
||||||
)}
|
)
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default ModalInput;
|
export default ModalInput
|
||||||
|
|
|
||||||
|
|
@ -1,122 +1,124 @@
|
||||||
|
import React from 'react'
|
||||||
|
import { t } from '@/scripts/i18n'
|
||||||
|
import PaginationItem from './PaginationItem'
|
||||||
|
|
||||||
import {t} from '@/scripts/i18n';
|
interface Props {
|
||||||
import PaginationItem from './PaginationItem';
|
page: number
|
||||||
|
totalPages: number
|
||||||
type Props = {
|
onChange(page: number): void | Promise<void>
|
||||||
readonly page: number;
|
}
|
||||||
readonly totalPages: number;
|
|
||||||
onChange: (page: number) => void | Promise<void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const labels = {
|
const labels = {
|
||||||
prev: '‹',
|
prev: '‹',
|
||||||
next: '›',
|
next: '›',
|
||||||
};
|
}
|
||||||
|
|
||||||
const Pagination: React.FC<Props> = props => {
|
const Pagination: React.FC<Props> = (props) => {
|
||||||
const {page, totalPages, onChange} = props;
|
const { page, totalPages, onChange } = props
|
||||||
|
|
||||||
if (totalPages < 1) {
|
if (totalPages < 1) {
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ul className='pagination'>
|
<ul className="pagination">
|
||||||
<PaginationItem
|
<PaginationItem
|
||||||
title={t('vendor.datatable.prev')}
|
title={t('vendor.datatable.prev')}
|
||||||
disabled={page === 1}
|
disabled={page === 1}
|
||||||
onClick={async () => onChange(page - 1)}
|
onClick={() => onChange(page - 1)}
|
||||||
>
|
>
|
||||||
{labels.prev}
|
{labels.prev}
|
||||||
<span className='d-inline d-sm-none ml-1'>
|
<span className="d-inline d-sm-none ml-1">
|
||||||
{t('vendor.datatable.prev')}
|
{t('vendor.datatable.prev')}
|
||||||
</span>
|
</span>
|
||||||
</PaginationItem>
|
</PaginationItem>
|
||||||
{totalPages < 8
|
{totalPages < 8 ? (
|
||||||
? Array.from({length: totalPages}).map((_, i) => (
|
Array.from({ length: totalPages }).map((_, i) => (
|
||||||
<PaginationItem
|
<PaginationItem
|
||||||
key={i}
|
key={i}
|
||||||
className='d-none d-sm-block'
|
className="d-none d-sm-block"
|
||||||
active={page === i + 1}
|
active={page === i + 1}
|
||||||
onClick={async () => onChange(i + 1)}
|
onClick={() => onChange(i + 1)}
|
||||||
>
|
>
|
||||||
{i + 1}
|
{i + 1}
|
||||||
</PaginationItem>
|
</PaginationItem>
|
||||||
))
|
))
|
||||||
: (
|
) : (
|
||||||
<>
|
<>
|
||||||
{page < 4
|
{page < 4 ? (
|
||||||
? [1, 2, 3, 4].map(n => (
|
[1, 2, 3, 4].map((n) => (
|
||||||
<PaginationItem
|
<PaginationItem
|
||||||
key={n}
|
key={n}
|
||||||
className='d-none d-sm-block'
|
className="d-none d-sm-block"
|
||||||
active={page === n}
|
active={page === n}
|
||||||
onClick={async () => onChange(n)}
|
onClick={() => onChange(n)}
|
||||||
>
|
>
|
||||||
{n}
|
{n}
|
||||||
</PaginationItem>
|
</PaginationItem>
|
||||||
))
|
))
|
||||||
: (
|
) : (
|
||||||
<PaginationItem
|
<PaginationItem
|
||||||
className='d-none d-sm-block'
|
className="d-none d-sm-block"
|
||||||
onClick={async () => onChange(1)}
|
onClick={() => onChange(1)}
|
||||||
>
|
>
|
||||||
1
|
1
|
||||||
</PaginationItem>
|
</PaginationItem>
|
||||||
)}
|
)}
|
||||||
<PaginationItem disabled className='d-none d-sm-block'>
|
<PaginationItem className="d-none d-sm-block" disabled>
|
||||||
...
|
...
|
||||||
</PaginationItem>
|
</PaginationItem>
|
||||||
{page > 3 && page < totalPages - 2 && (
|
{page > 3 && page < totalPages - 2 && (
|
||||||
<>
|
<>
|
||||||
{[page - 1, page, page + 1].map(n => (
|
{[page - 1, page, page + 1].map((n) => (
|
||||||
<PaginationItem
|
<PaginationItem
|
||||||
key={n}
|
key={n}
|
||||||
className='d-none d-sm-block'
|
className="d-none d-sm-block"
|
||||||
active={page === n}
|
active={page === n}
|
||||||
onClick={async () => onChange(n)}
|
onClick={() => onChange(n)}
|
||||||
>
|
>
|
||||||
{n}
|
{n}
|
||||||
</PaginationItem>
|
</PaginationItem>
|
||||||
))}
|
))}
|
||||||
<PaginationItem disabled className='d-none d-sm-block'>
|
<PaginationItem className="d-none d-sm-block" disabled>
|
||||||
...
|
...
|
||||||
</PaginationItem>
|
</PaginationItem>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{totalPages - page < 3
|
{totalPages - page < 3 ? (
|
||||||
? [totalPages - 3, totalPages - 2, totalPages - 1, totalPages].map(n => (
|
[totalPages - 3, totalPages - 2, totalPages - 1, totalPages].map(
|
||||||
<PaginationItem
|
(n) => (
|
||||||
key={n}
|
<PaginationItem
|
||||||
className='d-none d-sm-block'
|
key={n}
|
||||||
active={page === n}
|
className="d-none d-sm-block"
|
||||||
onClick={async () => onChange(n)}
|
active={page === n}
|
||||||
>
|
onClick={() => onChange(n)}
|
||||||
{n}
|
>
|
||||||
</PaginationItem>
|
{n}
|
||||||
))
|
</PaginationItem>
|
||||||
: (
|
),
|
||||||
<PaginationItem
|
)
|
||||||
className='d-none d-sm-block'
|
) : (
|
||||||
onClick={async () => onChange(totalPages)}
|
<PaginationItem
|
||||||
>
|
className="d-none d-sm-block"
|
||||||
{totalPages}
|
onClick={() => onChange(totalPages)}
|
||||||
</PaginationItem>
|
>
|
||||||
)}
|
{totalPages}
|
||||||
</>
|
</PaginationItem>
|
||||||
)}
|
)}
|
||||||
<PaginationItem
|
</>
|
||||||
title={t('vendor.datatable.next')}
|
)}
|
||||||
disabled={page === totalPages}
|
<PaginationItem
|
||||||
onClick={async () => onChange(page + 1)}
|
title={t('vendor.datatable.next')}
|
||||||
>
|
disabled={page === totalPages}
|
||||||
<span className='d-inline d-sm-none mr-1'>
|
onClick={() => onChange(page + 1)}
|
||||||
{t('vendor.datatable.next')}
|
>
|
||||||
</span>
|
<span className="d-inline d-sm-none mr-1">
|
||||||
{labels.next}
|
{t('vendor.datatable.next')}
|
||||||
</PaginationItem>
|
</span>
|
||||||
</ul>
|
{labels.next}
|
||||||
);
|
</PaginationItem>
|
||||||
};
|
</ul>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default Pagination;
|
export default Pagination
|
||||||
|
|
|
||||||
|
|
@ -1,41 +1,39 @@
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
type Props = {
|
interface Props {
|
||||||
readonly disabled?: boolean;
|
disabled?: boolean
|
||||||
readonly active?: boolean;
|
active?: boolean
|
||||||
readonly title?: string;
|
title?: string
|
||||||
readonly className?: string;
|
className?: string
|
||||||
onClick?: () => void;
|
onClick?(): void
|
||||||
readonly children?: React.ReactNode;
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const PaginationItem: React.FC<Props> = props => {
|
const PaginationItem: React.FC<Props> = (props) => {
|
||||||
const classes = ['page-item'];
|
const classes = ['page-item']
|
||||||
if (props.active) {
|
if (props.active) {
|
||||||
classes.push('active');
|
classes.push('active')
|
||||||
}
|
}
|
||||||
|
if (props.disabled) {
|
||||||
|
classes.push('disabled')
|
||||||
|
}
|
||||||
|
if (props.className) {
|
||||||
|
classes.push(props.className)
|
||||||
|
}
|
||||||
|
|
||||||
if (props.disabled) {
|
const handleClick = (event: React.MouseEvent) => {
|
||||||
classes.push('disabled');
|
event.preventDefault()
|
||||||
}
|
if (!props.disabled && props.onClick) {
|
||||||
|
props.onClick()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (props.className) {
|
return (
|
||||||
classes.push(props.className);
|
<li className={classes.join(' ')} title={props.title} onClick={handleClick}>
|
||||||
}
|
<a href="#" className="page-link" aria-disabled={props.disabled}>
|
||||||
|
{props.children}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const handleClick = (event: React.MouseEvent) => {
|
export default PaginationItem
|
||||||
event.preventDefault();
|
|
||||||
if (!props.disabled && props.onClick) {
|
|
||||||
props.onClick();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<li className={classes.join(' ')} title={props.title} onClick={handleClick}>
|
|
||||||
<a href='#' className='page-link' aria-disabled={props.disabled}>
|
|
||||||
{props.children}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PaginationItem;
|
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,21 @@
|
||||||
import {css} from '@emotion/react';
|
/** @jsxImportSource @emotion/react */
|
||||||
import {useEffect, useState} from 'react';
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { css } from '@emotion/react'
|
||||||
|
|
||||||
export type ToastType = 'success' | 'info' | 'warning' | 'error';
|
export type ToastType = 'success' | 'info' | 'warning' | 'error'
|
||||||
|
|
||||||
type Props = {
|
interface Props {
|
||||||
readonly type: ToastType;
|
type: ToastType
|
||||||
readonly distance: number;
|
distance: number
|
||||||
onClose: () => void | Promise<void>;
|
onClose(): void | Promise<void>
|
||||||
readonly children: React.ReactNode;
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const icons = new Map<ToastType, string>([
|
const icons = new Map<ToastType, string>([
|
||||||
['success', 'check'],
|
['success', 'check'],
|
||||||
['info', 'info'],
|
['info', 'info'],
|
||||||
['warning', 'exclamation-triangle'],
|
['warning', 'exclamation-triangle'],
|
||||||
['error', 'times-circle'],
|
['error', 'times-circle'],
|
||||||
]);
|
])
|
||||||
|
|
||||||
const wrapper = css`
|
const wrapper = css`
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|
@ -24,54 +24,52 @@ const wrapper = css`
|
||||||
z-index: 1050;
|
z-index: 1050;
|
||||||
transition-property: top;
|
transition-property: top;
|
||||||
transition-duration: 0.3s;
|
transition-duration: 0.3s;
|
||||||
`;
|
`
|
||||||
const shadow = css`
|
const shadow = css`
|
||||||
box-shadow: 0 0.25rem 0.75rem rgba(0, 0, 0, 0.1);
|
box-shadow: 0 0.25rem 0.75rem rgba(0, 0, 0, 0.1);
|
||||||
`;
|
`
|
||||||
|
|
||||||
const Toast: React.FC<Props> = props => {
|
const Toast: React.FC<Props> = (props) => {
|
||||||
const [show, setShow] = useState(false);
|
const [show, setShow] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => setShow(true), 100)
|
||||||
setShow(true);
|
|
||||||
}, 100);
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer)
|
||||||
};
|
}
|
||||||
}, [props.onClose]);
|
}, [props.onClose])
|
||||||
|
|
||||||
const type = props.type === 'error' ? 'danger' : props.type;
|
const type = props.type === 'error' ? 'danger' : props.type
|
||||||
|
|
||||||
const classes = [
|
const classes = [
|
||||||
`alert alert-${type}`,
|
`alert alert-${type}`,
|
||||||
'd-flex justify-content-between',
|
'd-flex justify-content-between',
|
||||||
'fade',
|
'fade',
|
||||||
];
|
]
|
||||||
if (show) {
|
if (show) {
|
||||||
classes.push('show');
|
classes.push('show')
|
||||||
}
|
}
|
||||||
|
|
||||||
const role = type === 'success' || type === 'info' ? 'status' : 'alert';
|
const role = type === 'success' || type === 'info' ? 'status' : 'alert'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div css={wrapper} style={{top: `${props.distance}px`}}>
|
<div css={wrapper} style={{ top: `${props.distance}px` }}>
|
||||||
<div className={classes.join(' ')} css={shadow} role={role}>
|
<div className={classes.join(' ')} css={shadow} role={role}>
|
||||||
<span className='mr-1 d-flex align-items-center'>
|
<span className="mr-1 d-flex align-items-center">
|
||||||
<i className={`icon fas fa-${icons.get(props.type)}`}/>
|
<i className={`icon fas fa-${icons.get(props.type)}`}></i>
|
||||||
</span>
|
</span>
|
||||||
<span>{props.children}</span>
|
<span>{props.children}</span>
|
||||||
<button
|
<button
|
||||||
type='button'
|
type="button"
|
||||||
className='mr-2 ml-1 close'
|
className="mr-2 ml-1 close"
|
||||||
onClick={props.onClose}
|
onClick={props.onClose}
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|
||||||
export default Toast;
|
export default Toast
|
||||||
|
|
|
||||||
|
|
@ -1,38 +1,38 @@
|
||||||
import {t} from '@/scripts/i18n';
|
/** @jsxImportSource @emotion/react */
|
||||||
import * as breakpoints from '@/styles/breakpoints';
|
import React, { useState, useEffect, useRef } from 'react'
|
||||||
import * as cssUtils from '@/styles/utils';
|
import { useMeasure } from 'react-use'
|
||||||
import {css} from '@emotion/react';
|
import { css } from '@emotion/react'
|
||||||
import styled from '@emotion/styled';
|
import styled from '@emotion/styled'
|
||||||
import {useEffect, useRef, useState} from 'react';
|
import * as skinview3d from 'skinview3d'
|
||||||
import {useMeasure} from 'react-use';
|
import { t } from '@/scripts/i18n'
|
||||||
import * as skinview3d from 'skinview3d';
|
import * as cssUtils from '@/styles/utils'
|
||||||
import bg1 from '../../../misc/backgrounds/1.webp';
|
import * as breakpoints from '@/styles/breakpoints'
|
||||||
import bg2 from '../../../misc/backgrounds/2.webp';
|
import SkinSteve from '../../../misc/textures/steve.png'
|
||||||
import bg3 from '../../../misc/backgrounds/3.webp';
|
import bg1 from '../../../misc/backgrounds/1.webp'
|
||||||
import bg4 from '../../../misc/backgrounds/4.webp';
|
import bg2 from '../../../misc/backgrounds/2.webp'
|
||||||
import bg5 from '../../../misc/backgrounds/5.webp';
|
import bg3 from '../../../misc/backgrounds/3.webp'
|
||||||
import bg6 from '../../../misc/backgrounds/6.webp';
|
import bg4 from '../../../misc/backgrounds/4.webp'
|
||||||
import bg7 from '../../../misc/backgrounds/7.webp';
|
import bg5 from '../../../misc/backgrounds/5.webp'
|
||||||
import SkinSteve from '../../../misc/textures/steve.png';
|
import bg6 from '../../../misc/backgrounds/6.webp'
|
||||||
|
import bg7 from '../../../misc/backgrounds/7.webp'
|
||||||
|
|
||||||
const backgrounds = [bg1, bg2, bg3, bg4, bg5, bg6, bg7];
|
const backgrounds = [bg1, bg2, bg3, bg4, bg5, bg6, bg7]
|
||||||
export const PICTURES_COUNT = backgrounds.length;
|
export const PICTURES_COUNT = backgrounds.length
|
||||||
|
|
||||||
type Props = {
|
interface Props {
|
||||||
readonly skin?: string;
|
skin?: string
|
||||||
readonly cape?: string;
|
cape?: string
|
||||||
readonly children?: React.ReactNode;
|
isAlex: boolean
|
||||||
readonly isAlex: boolean;
|
showIndicator?: boolean
|
||||||
readonly showIndicator?: boolean;
|
initPositionZ?: number
|
||||||
readonly initPositionZ?: number;
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const animationFactories = [
|
const animationFactories = [
|
||||||
() => new skinview3d.WalkingAnimation(),
|
() => new skinview3d.WalkingAnimation(),
|
||||||
() => new skinview3d.RunningAnimation(),
|
() => new skinview3d.RunningAnimation(),
|
||||||
() => new skinview3d.FlyingAnimation(),
|
() => new skinview3d.FlyingAnimation(),
|
||||||
() => new skinview3d.IdleAnimation(),
|
() => new skinview3d.IdleAnimation(),
|
||||||
];
|
]
|
||||||
|
|
||||||
const ActionButton = styled.i`
|
const ActionButton = styled.i`
|
||||||
display: inline;
|
display: inline;
|
||||||
|
|
@ -41,7 +41,7 @@ const ActionButton = styled.i`
|
||||||
color: #555;
|
color: #555;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
`;
|
`
|
||||||
|
|
||||||
const cssViewer = css`
|
const cssViewer = css`
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
|
|
@ -56,255 +56,251 @@ const cssViewer = css`
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
`;
|
`
|
||||||
|
|
||||||
const Viewer: React.FC<Props> = props => {
|
const Viewer: React.FC<Props> = (props) => {
|
||||||
const {initPositionZ = 70} = props;
|
const { initPositionZ = 70 } = props
|
||||||
|
|
||||||
const viewReference: React.MutableRefObject<skinview3d.SkinViewer> = useRef(null!);
|
const viewRef: React.MutableRefObject<skinview3d.SkinViewer> = useRef(null!)
|
||||||
const containerReference = useRef<HTMLCanvasElement>(null);
|
const containerRef = useRef<HTMLCanvasElement>(null)
|
||||||
|
|
||||||
const [paused, setPaused] = useState(false);
|
const [paused, setPaused] = useState(false)
|
||||||
const [animation, setAnimation] = useState(0);
|
const [animation, setAnimation] = useState(0)
|
||||||
const [bgPicture, setBgPicture] = useState(-1);
|
const [bgPicture, setBgPicture] = useState(-1)
|
||||||
|
|
||||||
const indicator = (() => {
|
const indicator = (() => {
|
||||||
const {skin, cape} = props;
|
const { skin, cape } = props
|
||||||
if (skin && cape) {
|
if (skin && cape) {
|
||||||
return `${t('general.skin')} & ${t('general.cape')}`;
|
return `${t('general.skin')} & ${t('general.cape')}`
|
||||||
}
|
} else if (skin) {
|
||||||
|
return t('general.skin')
|
||||||
|
} else if (cape) {
|
||||||
|
return t('general.cape')
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
})()
|
||||||
|
|
||||||
if (skin) {
|
useEffect(() => {
|
||||||
return t('general.skin');
|
const container = containerRef.current!
|
||||||
}
|
const viewer = new skinview3d.SkinViewer({
|
||||||
|
canvas: container,
|
||||||
|
width: container.clientWidth,
|
||||||
|
height: container.clientHeight,
|
||||||
|
skin: props.skin || SkinSteve,
|
||||||
|
cape: props.cape || undefined,
|
||||||
|
model: props.isAlex ? 'slim' : 'default',
|
||||||
|
zoom: initPositionZ / 100,
|
||||||
|
})
|
||||||
|
viewer.autoRotate = true
|
||||||
|
|
||||||
if (cape) {
|
if (document.body.classList.contains('dark-mode')) {
|
||||||
return t('general.cape');
|
viewer.background = '#6c757d'
|
||||||
}
|
}
|
||||||
|
|
||||||
return '';
|
viewRef.current = viewer
|
||||||
})();
|
|
||||||
|
|
||||||
useEffect(() => {
|
return () => {
|
||||||
const container = containerReference.current!;
|
viewer.dispose()
|
||||||
const viewer = new skinview3d.SkinViewer({
|
}
|
||||||
canvas: container,
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
width: container.clientWidth,
|
}, [])
|
||||||
height: container.clientHeight,
|
|
||||||
skin: props.skin || SkinSteve,
|
|
||||||
cape: props.cape || undefined,
|
|
||||||
model: props.isAlex ? 'slim' : 'default',
|
|
||||||
zoom: initPositionZ / 100,
|
|
||||||
});
|
|
||||||
viewer.autoRotate = true;
|
|
||||||
|
|
||||||
if (document.body.classList.contains('dark-mode')) {
|
const [containerWrapperRef, containerMeasure] = useMeasure<HTMLDivElement>()
|
||||||
viewer.background = '#6c757d';
|
useEffect(() => {
|
||||||
}
|
viewRef.current.setSize(containerMeasure.width, containerMeasure.height)
|
||||||
|
})
|
||||||
|
|
||||||
viewReference.current = viewer;
|
useEffect(() => {
|
||||||
|
const viewer = viewRef.current
|
||||||
|
viewer.loadSkin(props.skin || SkinSteve, {
|
||||||
|
model: props.isAlex ? 'slim' : 'default',
|
||||||
|
})
|
||||||
|
}, [props.skin, props.isAlex])
|
||||||
|
|
||||||
return () => {
|
useEffect(() => {
|
||||||
viewer.dispose();
|
const viewer = viewRef.current
|
||||||
};
|
if (props.cape) {
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
viewer.loadCape(props.cape)
|
||||||
}, []);
|
} else {
|
||||||
|
viewer.resetCape()
|
||||||
|
}
|
||||||
|
}, [props.cape])
|
||||||
|
|
||||||
const [containerWrapperReference, containerMeasure] = useMeasure<HTMLDivElement>();
|
useEffect(() => {
|
||||||
useEffect(() => {
|
const viewer = viewRef.current
|
||||||
viewReference.current.setSize(containerMeasure.width, containerMeasure.height);
|
const factory = animationFactories[animation]
|
||||||
});
|
if (factory === undefined) {
|
||||||
|
viewer.animation = null
|
||||||
|
} else {
|
||||||
|
const newAnimation = factory()
|
||||||
|
newAnimation.paused = paused // Perseve `paused` state
|
||||||
|
viewer.animation = newAnimation
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [animation])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const viewer = viewReference.current;
|
const currentAnimation = viewRef.current.animation
|
||||||
viewer.loadSkin(props.skin || SkinSteve, {
|
if (currentAnimation !== null) {
|
||||||
model: props.isAlex ? 'slim' : 'default',
|
currentAnimation.paused = paused
|
||||||
});
|
}
|
||||||
}, [props.skin, props.isAlex]);
|
}, [paused])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const viewer = viewReference.current;
|
const viewer = viewRef.current
|
||||||
if (props.cape) {
|
const backgroundUrl = backgrounds[bgPicture]
|
||||||
viewer.loadCape(props.cape);
|
if (backgroundUrl === undefined) {
|
||||||
} else {
|
viewer.background = null
|
||||||
viewer.resetCape();
|
} else {
|
||||||
}
|
viewer.loadBackground(backgroundUrl)
|
||||||
}, [props.cape]);
|
}
|
||||||
|
}, [bgPicture])
|
||||||
|
|
||||||
useEffect(() => {
|
const togglePause = () => {
|
||||||
const viewer = viewReference.current;
|
setPaused((paused) => {
|
||||||
const factory = animationFactories[animation];
|
if (paused) {
|
||||||
if (factory === undefined) {
|
return false
|
||||||
viewer.animation = null;
|
} else {
|
||||||
} else {
|
viewRef.current.autoRotate = false
|
||||||
const newAnimation = factory();
|
return true
|
||||||
newAnimation.paused = paused; // Perseve `paused` state
|
}
|
||||||
viewer.animation = newAnimation;
|
})
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [animation]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const toggleAnimation = () => {
|
||||||
const currentAnimation = viewReference.current.animation;
|
setAnimation((index) => (index + 1) % animationFactories.length)
|
||||||
if (currentAnimation !== null) {
|
setPaused(false)
|
||||||
currentAnimation.paused = paused;
|
}
|
||||||
}
|
|
||||||
}, [paused]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const toggleRotate = () => {
|
||||||
const viewer = viewReference.current;
|
const viewer = viewRef.current
|
||||||
const backgroundUrl = backgrounds[bgPicture];
|
viewer.autoRotate = !viewer.autoRotate
|
||||||
if (backgroundUrl === undefined) {
|
}
|
||||||
viewer.background = null;
|
|
||||||
} else {
|
|
||||||
viewer.loadBackground(backgroundUrl);
|
|
||||||
}
|
|
||||||
}, [bgPicture]);
|
|
||||||
|
|
||||||
const togglePause = () => {
|
const toggleBackEquippment = () => {
|
||||||
setPaused(paused => {
|
const player = viewRef.current.playerObject
|
||||||
if (paused) {
|
if (player.backEquipment === 'cape') {
|
||||||
return false;
|
player.backEquipment = 'elytra'
|
||||||
}
|
} else {
|
||||||
|
player.backEquipment = 'cape'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
viewReference.current.autoRotate = false;
|
const setWhite = () => {
|
||||||
return true;
|
viewRef.current.background = '#fff'
|
||||||
});
|
}
|
||||||
};
|
const setGray = () => {
|
||||||
|
viewRef.current.background = '#6c757d'
|
||||||
|
}
|
||||||
|
const setBlack = () => {
|
||||||
|
viewRef.current.background = '#000'
|
||||||
|
}
|
||||||
|
const setPrevPicture = () => {
|
||||||
|
setBgPicture((index) => {
|
||||||
|
if (bgPicture <= 0) {
|
||||||
|
return PICTURES_COUNT - 1
|
||||||
|
} else {
|
||||||
|
return index - 1
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const setNextPicture = () => {
|
||||||
|
setBgPicture((index) => {
|
||||||
|
if (bgPicture >= PICTURES_COUNT - 1) {
|
||||||
|
return 0
|
||||||
|
} else {
|
||||||
|
return index + 1
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const toggleAnimation = () => {
|
return (
|
||||||
setAnimation(index => (index + 1) % animationFactories.length);
|
<div className="card">
|
||||||
setPaused(false);
|
<div className="card-header">
|
||||||
};
|
<div className="d-flex justify-content-between">
|
||||||
|
<h3 className="card-title">
|
||||||
|
<span>{t('general.texturePreview')}</span>
|
||||||
|
{props.showIndicator && (
|
||||||
|
<span className="badge bg-olive ml-1">{indicator}</span>
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
|
<div>
|
||||||
|
<ActionButton
|
||||||
|
className={`fas fa-tablet ${props.cape ? '' : 'd-none'}`}
|
||||||
|
data-toggle="tooltip"
|
||||||
|
data-placement="bottom"
|
||||||
|
title={t('general.switchCapeElytra')}
|
||||||
|
onClick={toggleBackEquippment}
|
||||||
|
></ActionButton>
|
||||||
|
<ActionButton
|
||||||
|
className={`fas fa-person-running`}
|
||||||
|
data-toggle="tooltip"
|
||||||
|
data-placement="bottom"
|
||||||
|
title={t('general.switchAnimation')}
|
||||||
|
onClick={toggleAnimation}
|
||||||
|
></ActionButton>
|
||||||
|
<ActionButton
|
||||||
|
className={`fas fa-${paused ? 'play' : 'pause'}`}
|
||||||
|
data-toggle="tooltip"
|
||||||
|
data-placement="bottom"
|
||||||
|
title={
|
||||||
|
paused
|
||||||
|
? t('general.playAnimation')
|
||||||
|
: t('general.pauseAnimation')
|
||||||
|
}
|
||||||
|
onClick={togglePause}
|
||||||
|
></ActionButton>
|
||||||
|
<ActionButton
|
||||||
|
className="fas fa-rotate-right"
|
||||||
|
data-toggle="tooltip"
|
||||||
|
data-placement="bottom"
|
||||||
|
title={t('general.rotation')}
|
||||||
|
onClick={toggleRotate}
|
||||||
|
></ActionButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div ref={containerWrapperRef} css={cssViewer} className="p-0">
|
||||||
|
<canvas ref={containerRef}></canvas>
|
||||||
|
</div>
|
||||||
|
<div className="card-footer">
|
||||||
|
<div className="mt-2 mb-3 d-flex">
|
||||||
|
<div
|
||||||
|
className="btn-color bg-white rounded-pill mr-2 elevation-2"
|
||||||
|
title={t('colors.white')}
|
||||||
|
onClick={setWhite}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="btn-color bg-black rounded-pill mr-2 elevation-2"
|
||||||
|
title={t('colors.black')}
|
||||||
|
onClick={setBlack}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="btn-color bg-gray rounded-pill mr-2 elevation-2"
|
||||||
|
title={t('colors.gray')}
|
||||||
|
onClick={setGray}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="btn-color bg-green rounded-pill mr-2 elevation-2"
|
||||||
|
css={cssUtils.center}
|
||||||
|
title={t('colors.prev')}
|
||||||
|
onClick={setPrevPicture}
|
||||||
|
>
|
||||||
|
<i className="fas fa-arrow-left"></i>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="btn-color bg-green rounded-pill mr-2 elevation-2"
|
||||||
|
css={cssUtils.center}
|
||||||
|
title={t('colors.next')}
|
||||||
|
onClick={setNextPicture}
|
||||||
|
>
|
||||||
|
<i className="fas fa-arrow-right"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const toggleRotate = () => {
|
export default Viewer
|
||||||
const viewer = viewReference.current;
|
|
||||||
viewer.autoRotate = !viewer.autoRotate;
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleBackEquippment = () => {
|
|
||||||
const player = viewReference.current.playerObject;
|
|
||||||
player.backEquipment = player.backEquipment === 'cape' ? 'elytra' : 'cape';
|
|
||||||
};
|
|
||||||
|
|
||||||
const setWhite = () => {
|
|
||||||
viewReference.current.background = '#fff';
|
|
||||||
};
|
|
||||||
|
|
||||||
const setGray = () => {
|
|
||||||
viewReference.current.background = '#6c757d';
|
|
||||||
};
|
|
||||||
|
|
||||||
const setBlack = () => {
|
|
||||||
viewReference.current.background = '#000';
|
|
||||||
};
|
|
||||||
|
|
||||||
const setPreviousPicture = () => {
|
|
||||||
setBgPicture(index => {
|
|
||||||
if (bgPicture <= 0) {
|
|
||||||
return PICTURES_COUNT - 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return index - 1;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const setNextPicture = () => {
|
|
||||||
setBgPicture(index => {
|
|
||||||
if (bgPicture >= PICTURES_COUNT - 1) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
return index + 1;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='card'>
|
|
||||||
<div className='card-header'>
|
|
||||||
<div className='d-flex justify-content-between'>
|
|
||||||
<h3 className='card-title'>
|
|
||||||
<span>{t('general.texturePreview')}</span>
|
|
||||||
{props.showIndicator
|
|
||||||
&& <span className='badge bg-olive ml-1'>{indicator}</span>}
|
|
||||||
</h3>
|
|
||||||
<div>
|
|
||||||
<ActionButton
|
|
||||||
className={`fas fa-tablet ${props.cape ? '' : 'd-none'}`}
|
|
||||||
data-toggle='tooltip'
|
|
||||||
data-placement='bottom'
|
|
||||||
title={t('general.switchCapeElytra')}
|
|
||||||
onClick={toggleBackEquippment}
|
|
||||||
/>
|
|
||||||
<ActionButton
|
|
||||||
className='fas fa-person-running'
|
|
||||||
data-toggle='tooltip'
|
|
||||||
data-placement='bottom'
|
|
||||||
title={t('general.switchAnimation')}
|
|
||||||
onClick={toggleAnimation}
|
|
||||||
/>
|
|
||||||
<ActionButton
|
|
||||||
className={`fas fa-${paused ? 'play' : 'pause'}`}
|
|
||||||
data-toggle='tooltip'
|
|
||||||
data-placement='bottom'
|
|
||||||
title={
|
|
||||||
paused
|
|
||||||
? t('general.playAnimation')
|
|
||||||
: t('general.pauseAnimation')
|
|
||||||
}
|
|
||||||
onClick={togglePause}
|
|
||||||
/>
|
|
||||||
<ActionButton
|
|
||||||
className='fas fa-rotate-right'
|
|
||||||
data-toggle='tooltip'
|
|
||||||
data-placement='bottom'
|
|
||||||
title={t('general.rotation')}
|
|
||||||
onClick={toggleRotate}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div ref={containerWrapperReference} css={cssViewer} className='p-0'>
|
|
||||||
<canvas ref={containerReference}/>
|
|
||||||
</div>
|
|
||||||
<div className='card-footer'>
|
|
||||||
<div className='mt-2 mb-3 d-flex'>
|
|
||||||
<div
|
|
||||||
className='btn-color bg-white rounded-pill mr-2 elevation-2'
|
|
||||||
title={t('colors.white')}
|
|
||||||
onClick={setWhite}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className='btn-color bg-black rounded-pill mr-2 elevation-2'
|
|
||||||
title={t('colors.black')}
|
|
||||||
onClick={setBlack}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className='btn-color bg-gray rounded-pill mr-2 elevation-2'
|
|
||||||
title={t('colors.gray')}
|
|
||||||
onClick={setGray}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className='btn-color bg-green rounded-pill mr-2 elevation-2'
|
|
||||||
css={cssUtils.center}
|
|
||||||
title={t('colors.prev')}
|
|
||||||
onClick={setPreviousPicture}
|
|
||||||
>
|
|
||||||
<i className='fas fa-arrow-left'/>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className='btn-color bg-green rounded-pill mr-2 elevation-2'
|
|
||||||
css={cssUtils.center}
|
|
||||||
title={t('colors.next')}
|
|
||||||
onClick={setNextPicture}
|
|
||||||
>
|
|
||||||
<i className='fas fa-arrow-right'/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{props.children}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Viewer;
|
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,17 @@
|
||||||
import {t} from '@/scripts/i18n';
|
import React from 'react'
|
||||||
|
import { t } from '@/scripts/i18n'
|
||||||
|
|
||||||
export default function ViewerSkeleton() {
|
const ViewerSkeleton: React.FC = () => (
|
||||||
return (
|
<div className="card">
|
||||||
<div className='card'>
|
<div className="card-header">
|
||||||
<div className='card-header'>
|
<div className="d-flex justify-content-between">
|
||||||
<div className='d-flex justify-content-between'>
|
<h3 className="card-title">
|
||||||
<h3 className='card-title'>
|
<span>{t('general.texturePreview')}</span>
|
||||||
<span>{t('general.texturePreview')}</span>
|
</h3>
|
||||||
</h3>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="card-body"></div>
|
||||||
<div className='card-body'/>
|
</div>
|
||||||
</div>
|
)
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
export default ViewerSkeleton
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,37 @@
|
||||||
import $ from 'jquery';
|
import * as React from 'react'
|
||||||
import React from 'react';
|
import * as ReactDOM from 'react-dom'
|
||||||
import ReactDOM from 'react-dom';
|
import $ from 'jquery'
|
||||||
import {createRoot} from 'react-dom/client';
|
import './scripts/app'
|
||||||
import routes from './scripts/route';
|
import routes from './scripts/route'
|
||||||
|
|
||||||
import './scripts/app';
|
Object.assign(window, { React, ReactDOM, $ })
|
||||||
|
|
||||||
// eslint-disable-next-line ts/naming-convention
|
const entry = document.querySelector('[href="#launch-cli"]')
|
||||||
Object.assign(window, {React, ReactDOM, $});
|
entry?.addEventListener('click', async () => {
|
||||||
|
const { launch } = await import('./scripts/cli')
|
||||||
|
launch()
|
||||||
|
})
|
||||||
|
|
||||||
const route = routes.find(route =>
|
const route = routes.find((route) =>
|
||||||
new RegExp(`^${route.path}$`, 'i').test(blessing.route));
|
new RegExp(`^${route.path}$`, 'i').test(blessing.route),
|
||||||
|
)
|
||||||
if (route) {
|
if (route) {
|
||||||
if (route.module) {
|
if (route.module) {
|
||||||
void Promise.all(route.module.map(async m => m()));
|
Promise.all(route.module.map((m) => m()))
|
||||||
}
|
}
|
||||||
|
if (route.react) {
|
||||||
if (route.react) {
|
const Component = React.lazy(
|
||||||
const Component = React.lazy(route.react as () => Promise<{default: React.ComponentType}>);
|
route.react as () => Promise<{ default: React.ComponentType }>,
|
||||||
|
)
|
||||||
const container = typeof route.el === 'string'
|
const Root = () => (
|
||||||
? document.querySelector(route.el)
|
<React.StrictMode>
|
||||||
: null;
|
<React.Suspense fallback={route.frame?.() ?? ''}>
|
||||||
|
<Component />
|
||||||
const root = createRoot(container!);
|
</React.Suspense>
|
||||||
root.render((
|
</React.StrictMode>
|
||||||
<React.StrictMode>
|
)
|
||||||
<React.Suspense fallback={route.frame?.() ?? ''}>
|
const c =
|
||||||
<Component/>
|
typeof route.el === 'string' ? document.querySelector(route.el) : route.el
|
||||||
</React.Suspense>
|
ReactDOM.render(<Root />, c)
|
||||||
</React.StrictMode>
|
}
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,14 @@
|
||||||
import {Tooltip} from 'bootstrap';
|
import './init' // must be first
|
||||||
import '@popperjs/core';
|
import 'admin-lte'
|
||||||
import 'admin-lte';
|
import './extra'
|
||||||
import './extra';
|
import './i18n'
|
||||||
import './i18n';
|
import './net'
|
||||||
import './net';
|
import './event'
|
||||||
import './event';
|
import './notification'
|
||||||
import './notification';
|
import './emailVerification'
|
||||||
import './emailVerification';
|
import './logout'
|
||||||
import './logout';
|
import './darkMode'
|
||||||
import './darkMode';
|
|
||||||
|
|
||||||
window.addEventListener('load', () => {
|
window.addEventListener('load', () => {
|
||||||
[...document.querySelectorAll('[data-toggle="tooltip"]')].map(el => new Tooltip(el));
|
$('[data-toggle="tooltip"]').tooltip()
|
||||||
});
|
})
|
||||||
|
|
|
||||||
140
resources/assets/src/scripts/cli.tsx
Normal file
140
resources/assets/src/scripts/cli.tsx
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
import React, { useEffect, useRef } from 'react'
|
||||||
|
import ReactDOM from 'react-dom'
|
||||||
|
import styled from '@emotion/styled'
|
||||||
|
import { Terminal } from 'xterm'
|
||||||
|
import { FitAddon } from 'xterm-addon-fit'
|
||||||
|
import { Shell, Stdio } from 'blessing-skin-shell'
|
||||||
|
import 'xterm/css/xterm.css'
|
||||||
|
import Draggable from 'react-draggable'
|
||||||
|
import * as event from './event'
|
||||||
|
import AptCommand from './cli/AptCommand'
|
||||||
|
import ClosetCommand from './cli/ClosetCommand'
|
||||||
|
import DnfCommand from './cli/DnfCommand'
|
||||||
|
import PacmanCommand from './cli/PacmanCommand'
|
||||||
|
import RmCommand from './cli/RmCommand'
|
||||||
|
import * as breakpoints from '@/styles/breakpoints'
|
||||||
|
|
||||||
|
let launched = false
|
||||||
|
|
||||||
|
const TerminalContainer = styled.div`
|
||||||
|
z-index: 1040;
|
||||||
|
position: fixed;
|
||||||
|
bottom: 7vh;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
background-color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
${breakpoints.greaterThan(breakpoints.Breakpoint.xl)} {
|
||||||
|
left: 25vw;
|
||||||
|
width: 50vw;
|
||||||
|
height: 50vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
${breakpoints.between(breakpoints.Breakpoint.md, breakpoints.Breakpoint.xl)} {
|
||||||
|
left: 5vw;
|
||||||
|
width: 90vw;
|
||||||
|
height: 40vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
${breakpoints.lessThan(breakpoints.Breakpoint.md)} {
|
||||||
|
left: 1vw;
|
||||||
|
width: 98vw;
|
||||||
|
height: 35vh;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const TerminalWindow: React.FC<{ onClose(): void }> = (props) => {
|
||||||
|
const mount = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = mount.current
|
||||||
|
if (!el) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const terminal = new Terminal()
|
||||||
|
const fitAddon = new FitAddon()
|
||||||
|
terminal.loadAddon(fitAddon)
|
||||||
|
terminal.setOption(
|
||||||
|
'fontFamily',
|
||||||
|
'Monaco, Consolas, "Roboto Mono", "Noto Sans", "Droid Sans Mono"',
|
||||||
|
)
|
||||||
|
terminal.open(el)
|
||||||
|
fitAddon.fit()
|
||||||
|
|
||||||
|
const programs = new Map<string, (stdio: Stdio, args: string[]) => void>()
|
||||||
|
programs.set('apt', AptCommand)
|
||||||
|
programs.set('closet', ClosetCommand)
|
||||||
|
programs.set('dnf', DnfCommand)
|
||||||
|
programs.set('pacman', PacmanCommand)
|
||||||
|
programs.set('rm', RmCommand)
|
||||||
|
event.emit('registerCLIPrograms', programs)
|
||||||
|
|
||||||
|
const shell = new Shell(terminal)
|
||||||
|
programs.forEach((program, name) => {
|
||||||
|
shell.addExternal(name, program)
|
||||||
|
})
|
||||||
|
|
||||||
|
const originalLogger = console.log
|
||||||
|
console.log = (data: string, ...args: any[]) => {
|
||||||
|
const stack = new Error().stack
|
||||||
|
if (stack?.includes('outputHelp')) {
|
||||||
|
terminal.writeln(data.replace(/\n/g, '\r\n'))
|
||||||
|
} else {
|
||||||
|
originalLogger(data, ...args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const unbindData = terminal.onData((e) => shell.input(e))
|
||||||
|
const unbindKey = terminal.onKey((e) =>
|
||||||
|
event.emit('terminalKeyPress', e.key),
|
||||||
|
)
|
||||||
|
launched = true
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unbindData.dispose()
|
||||||
|
unbindKey.dispose()
|
||||||
|
shell.free()
|
||||||
|
fitAddon.dispose()
|
||||||
|
terminal.dispose()
|
||||||
|
console.log = originalLogger
|
||||||
|
launched = false
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Draggable handle=".card-header">
|
||||||
|
<TerminalContainer className="card">
|
||||||
|
<div className="card-header">
|
||||||
|
<div className="d-flex justify-content-between">
|
||||||
|
<h4 className="card-title d-flex align-items-center">
|
||||||
|
Blessing Skin Shell
|
||||||
|
</h4>
|
||||||
|
<button className="btn btn-default" onClick={props.onClose}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="card-body p-2" ref={mount}></div>
|
||||||
|
</TerminalContainer>
|
||||||
|
</Draggable>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function launch() {
|
||||||
|
if (launched) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = document.createElement('div')
|
||||||
|
document.body.appendChild(container)
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
ReactDOM.unmountComponentAtNode(container)
|
||||||
|
container.remove()
|
||||||
|
}
|
||||||
|
|
||||||
|
ReactDOM.render(<TerminalWindow onClose={handleClose} />, container)
|
||||||
|
}
|
||||||
2
resources/assets/src/scripts/cli/.eslintrc.yml
Normal file
2
resources/assets/src/scripts/cli/.eslintrc.yml
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
rules:
|
||||||
|
'@typescript-eslint/no-empty-function': off
|
||||||
23
resources/assets/src/scripts/cli/AptCommand.ts
Normal file
23
resources/assets/src/scripts/cli/AptCommand.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import type { Stdio } from 'blessing-skin-shell'
|
||||||
|
import cac from 'cac'
|
||||||
|
import { install, remove } from './pluginManager'
|
||||||
|
|
||||||
|
export default async function apt(stdio: Stdio, args: string[]) {
|
||||||
|
const program = cac('apt')
|
||||||
|
program.help()
|
||||||
|
|
||||||
|
program
|
||||||
|
.command('install <plugin>', 'install a new plugin')
|
||||||
|
.action((plugin: string) => install(plugin, stdio))
|
||||||
|
|
||||||
|
program
|
||||||
|
.command('upgrade <plugin>', 'upgrade an existed plugin')
|
||||||
|
.action((plugin: string) => install(plugin, stdio))
|
||||||
|
|
||||||
|
program
|
||||||
|
.command('remove <plugin>', 'remove a plugin')
|
||||||
|
.action((plugin: string) => remove(plugin, stdio))
|
||||||
|
|
||||||
|
program.parse(['', ''].concat(args), { run: false })
|
||||||
|
await program.runMatchedCommand()
|
||||||
|
}
|
||||||
46
resources/assets/src/scripts/cli/ClosetCommand.ts
Normal file
46
resources/assets/src/scripts/cli/ClosetCommand.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import type { Stdio } from 'blessing-skin-shell'
|
||||||
|
import cac from 'cac'
|
||||||
|
import * as fetch from '../net'
|
||||||
|
import type { User, Texture } from '../types'
|
||||||
|
|
||||||
|
type Response = fetch.ResponseBody<{ user: User; texture: Texture }>
|
||||||
|
|
||||||
|
export default async function closet(stdio: Stdio, args: string[]) {
|
||||||
|
const program = cac('closet')
|
||||||
|
program.help()
|
||||||
|
|
||||||
|
program
|
||||||
|
.command('add <uid> <tid>', "add texture to someone's closet")
|
||||||
|
.action(async (uid: string, tid: string) => {
|
||||||
|
const { code, data } = await fetch.post<Response>(
|
||||||
|
`/admin/closet/${uid}`,
|
||||||
|
{ tid },
|
||||||
|
)
|
||||||
|
if (code === 0) {
|
||||||
|
const { texture, user } = data
|
||||||
|
stdio.println(
|
||||||
|
`Texture "${texture.name}" was added to user ${user.nickname}'s closet.`,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
stdio.println('Error occurred.')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
program
|
||||||
|
.command('remove <uid> <tid>', "remove texture from someone's closet")
|
||||||
|
.action(async (uid: string, tid: string) => {
|
||||||
|
const { code, data } = await fetch.del<Response>(`/admin/closet/${uid}`, {
|
||||||
|
tid,
|
||||||
|
})
|
||||||
|
if (code === 0) {
|
||||||
|
const { texture, user } = data
|
||||||
|
stdio.println(
|
||||||
|
`Texture "${texture.name}" was removed from user ${user.nickname}'s closet.`,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
stdio.println('Error occurred.')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
program.parse(['', ''].concat(args), { run: false })
|
||||||
|
await program.runMatchedCommand()
|
||||||
|
}
|
||||||
23
resources/assets/src/scripts/cli/DnfCommand.ts
Normal file
23
resources/assets/src/scripts/cli/DnfCommand.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import type { Stdio } from 'blessing-skin-shell'
|
||||||
|
import cac from 'cac'
|
||||||
|
import { install, remove } from './pluginManager'
|
||||||
|
|
||||||
|
export default async function dnf(stdio: Stdio, args: string[]) {
|
||||||
|
const program = cac('dnf')
|
||||||
|
program.help()
|
||||||
|
|
||||||
|
program
|
||||||
|
.command('install <plugin>', 'install a new plugin')
|
||||||
|
.action((plugin: string) => install(plugin, stdio))
|
||||||
|
|
||||||
|
program
|
||||||
|
.command('upgrade <plugin>', 'upgrade an existed plugin')
|
||||||
|
.action((plugin: string) => install(plugin, stdio))
|
||||||
|
|
||||||
|
program
|
||||||
|
.command('remove <plugin>', 'remove a plugin')
|
||||||
|
.action((plugin: string) => remove(plugin, stdio))
|
||||||
|
|
||||||
|
program.parse(['', ''].concat(args), { run: false })
|
||||||
|
await program.runMatchedCommand()
|
||||||
|
}
|
||||||
31
resources/assets/src/scripts/cli/PacmanCommand.ts
Normal file
31
resources/assets/src/scripts/cli/PacmanCommand.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import type { Stdio } from 'blessing-skin-shell'
|
||||||
|
import cac from 'cac'
|
||||||
|
import { install, remove } from './pluginManager'
|
||||||
|
|
||||||
|
type Options = {
|
||||||
|
sync?: string
|
||||||
|
remove?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function pacman(stdio: Stdio, args: string[]) {
|
||||||
|
if (args.length === 0) {
|
||||||
|
stdio.println('error: no operation specified (use -h for help)')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const program = cac('pacman')
|
||||||
|
program.help()
|
||||||
|
|
||||||
|
program.option('-S, --sync <plugin>', 'install or upgrade a plugin')
|
||||||
|
program.option('-R, --remove <plugin>', 'remove a plugin')
|
||||||
|
|
||||||
|
const { options } = program.parse(['', ''].concat(args), { run: false })
|
||||||
|
|
||||||
|
const opts: Options = options
|
||||||
|
/* istanbul ignore else */
|
||||||
|
if (opts.sync) {
|
||||||
|
await install(opts.sync, stdio)
|
||||||
|
} else if (opts.remove) {
|
||||||
|
await remove(opts.remove, stdio)
|
||||||
|
}
|
||||||
|
}
|
||||||
40
resources/assets/src/scripts/cli/RmCommand.ts
Normal file
40
resources/assets/src/scripts/cli/RmCommand.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import type { Stdio } from 'blessing-skin-shell'
|
||||||
|
import cac from 'cac'
|
||||||
|
import * as fetch from '../net'
|
||||||
|
|
||||||
|
type Options = {
|
||||||
|
force?: boolean
|
||||||
|
recursive?: boolean
|
||||||
|
help?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function rm(stdio: Stdio, args: string[]) {
|
||||||
|
const program = cac('rm')
|
||||||
|
program.help()
|
||||||
|
|
||||||
|
program
|
||||||
|
.command('<file>')
|
||||||
|
.option(
|
||||||
|
'-f, --force',
|
||||||
|
'ignore nonexistent files and arguments, never prompt',
|
||||||
|
)
|
||||||
|
.option(
|
||||||
|
'-r, --recursive',
|
||||||
|
'remove directories and their contents recursively',
|
||||||
|
)
|
||||||
|
.option('--no-preserve-root', "do not treat '/' specially")
|
||||||
|
|
||||||
|
const opts: Options = program.parse(['', ''].concat(args), {
|
||||||
|
run: false,
|
||||||
|
}).options
|
||||||
|
const path = program.args[0]
|
||||||
|
|
||||||
|
if (!path && !opts.help) {
|
||||||
|
stdio.println('rm: missing operand')
|
||||||
|
stdio.println("Try 'rm --help' for more information.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.force && opts.recursive && path?.startsWith('/')) {
|
||||||
|
await fetch.post('/admin/resource?clear-cache')
|
||||||
|
}
|
||||||
|
}
|
||||||
28
resources/assets/src/scripts/cli/Spinner.ts
Normal file
28
resources/assets/src/scripts/cli/Spinner.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import type { Stdio } from 'blessing-skin-shell'
|
||||||
|
import spinners from 'cli-spinners/spinners.json'
|
||||||
|
|
||||||
|
const { dots } = spinners
|
||||||
|
|
||||||
|
export class Spinner {
|
||||||
|
private timerId = 0
|
||||||
|
private index = 0
|
||||||
|
|
||||||
|
constructor(private stdio: Stdio) {}
|
||||||
|
|
||||||
|
start(message = '') {
|
||||||
|
this.timerId = window.setInterval(() => {
|
||||||
|
this.index += 1
|
||||||
|
this.index %= dots.frames.length
|
||||||
|
|
||||||
|
this.stdio.reset()
|
||||||
|
this.stdio.print(`${dots.frames[this.index]} ${message}`)
|
||||||
|
}, dots.interval)
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(message = '') {
|
||||||
|
clearInterval(this.timerId)
|
||||||
|
this.stdio.reset()
|
||||||
|
this.stdio.println(message)
|
||||||
|
this.stdio.print('\u001B[?25h')
|
||||||
|
}
|
||||||
|
}
|
||||||
35
resources/assets/src/scripts/cli/configureStdio.ts
Normal file
35
resources/assets/src/scripts/cli/configureStdio.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import type { Stdio } from 'blessing-skin-shell'
|
||||||
|
import * as event from '../event'
|
||||||
|
|
||||||
|
/* istanbul ignore next */
|
||||||
|
export function hackStdin() {
|
||||||
|
if (process.env.NODE_ENV === 'test') {
|
||||||
|
return process.stdin
|
||||||
|
}
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
return {
|
||||||
|
on(eventName: string, handler: (str: string, key: string) => void) {
|
||||||
|
if (eventName === 'keypress') {
|
||||||
|
this._off = event.on('terminalKeyPress', (key: string) => {
|
||||||
|
handler(key, key)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
isTTY: true,
|
||||||
|
setRawMode() {},
|
||||||
|
removeListener() {
|
||||||
|
this._off()
|
||||||
|
},
|
||||||
|
} as NodeJS.ReadStream & { _off(): void }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* istanbul ignore next */
|
||||||
|
export function hackStdout(stdio: Stdio) {
|
||||||
|
return {
|
||||||
|
write(msg: string) {
|
||||||
|
stdio.print(msg.replace(/\n/g, '\r\n'))
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
} as NodeJS.WriteStream
|
||||||
|
}
|
||||||
43
resources/assets/src/scripts/cli/pluginManager.ts
Normal file
43
resources/assets/src/scripts/cli/pluginManager.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import type { Stdio } from 'blessing-skin-shell'
|
||||||
|
import prompts from 'prompts'
|
||||||
|
import * as fetch from '../net'
|
||||||
|
import { hackStdout, hackStdin } from './configureStdio'
|
||||||
|
import { Spinner } from './Spinner'
|
||||||
|
|
||||||
|
export async function install(plugin: string, stdio: Stdio) {
|
||||||
|
const spinner = new Spinner(stdio)
|
||||||
|
spinner.start('Installing plugin...')
|
||||||
|
|
||||||
|
const { message, data } = await fetch.post<
|
||||||
|
fetch.ResponseBody<{ reason?: string[] } | undefined>
|
||||||
|
>('/admin/plugins/market/download', { name: plugin })
|
||||||
|
|
||||||
|
spinner.stop(` ${message}`)
|
||||||
|
const reasons = data?.reason
|
||||||
|
if (reasons) {
|
||||||
|
stdio.println(reasons.map((reason) => `- ${reason}`).join('\r\n'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function remove(plugin: string, stdio: Stdio) {
|
||||||
|
const { confirm }: { confirm: boolean } = await prompts({
|
||||||
|
name: 'confirm',
|
||||||
|
type: 'confirm',
|
||||||
|
message: `Are you sure to remove plugin "${plugin}"?`,
|
||||||
|
stdin: hackStdin(),
|
||||||
|
stdout: hackStdout(stdio),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!confirm) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const spinner = new Spinner(stdio)
|
||||||
|
spinner.start('Uninstalling plugin...')
|
||||||
|
|
||||||
|
const { message } = await fetch.post<fetch.ResponseBody>(
|
||||||
|
'/admin/plugins/manage',
|
||||||
|
{ action: 'delete', name: plugin },
|
||||||
|
)
|
||||||
|
spinner.stop(` ${message}`)
|
||||||
|
}
|
||||||
9
resources/assets/src/scripts/cli/readline.ts
Normal file
9
resources/assets/src/scripts/cli/readline.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
export function emitKeypressEvents() {}
|
||||||
|
|
||||||
|
export function createInterface() {
|
||||||
|
return {
|
||||||
|
pause() {},
|
||||||
|
resume() {},
|
||||||
|
close() {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import DarkModeButton from '@/components/DarkModeButton';
|
import * as React from 'react'
|
||||||
import ReactDOM from 'react-dom';
|
import * as ReactDOM from 'react-dom'
|
||||||
|
import DarkModeButton from '@/components/DarkModeButton'
|
||||||
|
|
||||||
const element = document.querySelector('#toggle-dark-mode');
|
const el = document.querySelector('#toggle-dark-mode')
|
||||||
if (element) {
|
if (el) {
|
||||||
const initMode = document.body.classList.contains('dark-mode');
|
const initMode = document.body.classList.contains('dark-mode')
|
||||||
ReactDOM.render(<DarkModeButton initMode={initMode}/>, element);
|
ReactDOM.render(<DarkModeButton initMode={initMode} />, el)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import EmailVerification from '@/views/widgets/EmailVerification';
|
import React from 'react'
|
||||||
import ReactDOM from 'react-dom';
|
import ReactDOM from 'react-dom'
|
||||||
|
import EmailVerification from '@/views/widgets/EmailVerification'
|
||||||
|
|
||||||
const container = document.querySelector('#email-verification');
|
const container = document.querySelector('#email-verification')
|
||||||
|
|
||||||
if (blessing.extra.unverified && container) {
|
if (blessing.extra.unverified && container) {
|
||||||
ReactDOM.render(<EmailVerification/>, container);
|
ReactDOM.render(<EmailVerification />, container)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,20 @@
|
||||||
const bus = new Map<string | symbol, Set<(...args: any[]) => void>>();
|
/* eslint-disable @typescript-eslint/no-unnecessary-condition */
|
||||||
|
const bus = new Map<string | symbol, Set<CallableFunction>>()
|
||||||
|
|
||||||
export function on(event: string | symbol, listener: (...args: any[]) => void) {
|
export function on(event: string | symbol, listener: CallableFunction) {
|
||||||
if (!bus.has(event)) {
|
if (!bus.has(event)) {
|
||||||
bus.set(event, new Set());
|
bus.set(event, new Set())
|
||||||
}
|
}
|
||||||
|
const listeners = bus.get(event)!
|
||||||
|
listeners.add(listener)
|
||||||
|
|
||||||
const listeners = bus.get(event)!;
|
return () => {
|
||||||
listeners.add(listener);
|
listeners.delete(listener)
|
||||||
|
}
|
||||||
return () => {
|
|
||||||
listeners.delete(listener);
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function emit(event: string | symbol, payload?: unknown) {
|
export function emit(event: string | symbol, payload?: unknown) {
|
||||||
bus.get(event)?.forEach(listener => {
|
bus.get(event)?.forEach((listener) => listener(payload))
|
||||||
listener(payload);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
blessing.event = {on, emit};
|
blessing.event = { on, emit }
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
export function getExtraData(): Record<string, any> {
|
export function getExtraData(): Record<string, any> {
|
||||||
const jsonElement = document.querySelector('#blessing-extra');
|
const jsonElement = document.querySelector('#blessing-extra')
|
||||||
|
/* istanbul ignore next */
|
||||||
if (jsonElement) {
|
if (jsonElement) {
|
||||||
return JSON.parse(jsonElement.textContent ?? '{}');
|
return JSON.parse(jsonElement.textContent ?? '{}')
|
||||||
}
|
} else {
|
||||||
|
return {}
|
||||||
return {};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
blessing.extra = getExtraData();
|
blessing.extra = getExtraData()
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,25 @@
|
||||||
import {getExtraData} from './extra';
|
import { getExtraData } from './extra'
|
||||||
|
|
||||||
export function scrollHander() {
|
export function scrollHander() {
|
||||||
const header = document.querySelector('.navbar');
|
const header = document.querySelector('.navbar')
|
||||||
/* istanbul ignore else */
|
/* istanbul ignore else */
|
||||||
if (header) {
|
if (header) {
|
||||||
window.addEventListener('scroll', () => {
|
window.addEventListener('scroll', () => {
|
||||||
if (window.scrollY >= (window.innerHeight * 2) / 3) {
|
if (window.scrollY >= (window.innerHeight * 2) / 3) {
|
||||||
header.classList.remove('transparent');
|
header.classList.remove('transparent')
|
||||||
} else {
|
} else {
|
||||||
header.classList.add('transparent');
|
header.classList.add('transparent')
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* istanbul ignore next */
|
||||||
if (process.env.NODE_ENV !== 'test') {
|
if (process.env.NODE_ENV !== 'test') {
|
||||||
const {transparent_navbar} = getExtraData() as {
|
const { transparent_navbar } = getExtraData() as {
|
||||||
transparent_navbar: boolean;
|
transparent_navbar: boolean
|
||||||
};
|
}
|
||||||
if (transparent_navbar) {
|
if (transparent_navbar) {
|
||||||
window.addEventListener('load', scrollHander);
|
window.addEventListener('load', scrollHander)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import {useEffect, useState} from 'react';
|
import { useState, useEffect } from 'react'
|
||||||
|
|
||||||
export default function useBlessingExtra<T>(key: string, defaultValue?: T): T {
|
export default function useBlessingExtra<T>(key: string, defaultValue?: T): T {
|
||||||
const [value, setValue] = useState<T>(defaultValue!);
|
const [value, setValue] = useState<T>(defaultValue!)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setValue(blessing.extra[key] as T);
|
setValue(blessing.extra[key] as T)
|
||||||
}, [key]);
|
}, [key])
|
||||||
|
|
||||||
return value;
|
return value
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import {useEffect} from 'react';
|
import { useEffect } from 'react'
|
||||||
import {emit} from '../event';
|
import { emit } from '../event'
|
||||||
|
|
||||||
export default function useEmitMounted() {
|
export default function useEmitMounted() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
emit('mounted');
|
emit('mounted')
|
||||||
}, []);
|
}, [])
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
import {useEffect, useState} from 'react';
|
import { useState, useEffect } from 'react'
|
||||||
|
|
||||||
export default function useIsLargeScreen() {
|
export default function useIsLargeScreen() {
|
||||||
const [isLarge, setIsLarge] = useState(false);
|
const [isLarge, setIsLarge] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (window.innerWidth >= 992) {
|
if (window.innerWidth >= 992) {
|
||||||
setIsLarge(true);
|
setIsLarge(true)
|
||||||
}
|
}
|
||||||
}, []);
|
}, [])
|
||||||
|
|
||||||
return isLarge;
|
return isLarge
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,20 @@
|
||||||
import {useEffect, useRef} from 'react';
|
import { useEffect, useRef } from 'react'
|
||||||
|
|
||||||
export default function useMount(selector: string): HTMLElement | undefined {
|
export default function useMount(selector: string): HTMLElement | null {
|
||||||
const container = useRef<HTMLDivElement | undefined>(null);
|
const container = useRef<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const mount = document.querySelector(selector)!;
|
const mount = document.querySelector(selector)!
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div')
|
||||||
container.current = div;
|
container.current = div
|
||||||
|
|
||||||
mount.append(div);
|
mount.appendChild(div)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
div.remove();
|
mount.removeChild(div)
|
||||||
container.current = null;
|
container.current = null
|
||||||
};
|
}
|
||||||
}, [selector]);
|
}, [selector])
|
||||||
|
|
||||||
return container.current;
|
return container.current
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,26 @@
|
||||||
import {useEffect, useState} from 'react';
|
import { useState, useEffect } from 'react'
|
||||||
import * as fetch from '../net';
|
import * as fetch from '../net'
|
||||||
import {type Texture, TextureType} from '../types';
|
import { Texture, TextureType } from '../types'
|
||||||
|
|
||||||
export default function useTexture() {
|
export default function useTexture() {
|
||||||
const [tid, setTid] = useState(0);
|
const [tid, setTid] = useState(0)
|
||||||
const [url, setUrl] = useState('');
|
const [url, setUrl] = useState('')
|
||||||
const [type, setType] = useState(TextureType.Steve);
|
const [type, setType] = useState(TextureType.Steve)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (tid <= 0) {
|
if (tid <= 0) {
|
||||||
setUrl('');
|
setUrl('')
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const getTexture = async () => {
|
const getTexture = async () => {
|
||||||
const {hash, type} = await fetch.get<Texture>(`/skinlib/info/${tid}`);
|
const { hash, type } = await fetch.get<Texture>(`/skinlib/info/${tid}`)
|
||||||
|
|
||||||
setUrl(`${blessing.base_url}/textures/${hash}`);
|
setUrl(`${blessing.base_url}/textures/${hash}`)
|
||||||
setType(type);
|
setType(type)
|
||||||
};
|
}
|
||||||
|
getTexture()
|
||||||
|
}, [tid])
|
||||||
|
|
||||||
getTexture();
|
return [{ url, type }, setTid] as const
|
||||||
}, [tid]);
|
|
||||||
|
|
||||||
return [{url, type}, setTid] as const;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,22 @@
|
||||||
import TWEEN from '@tweenjs/tween.js';
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import {useEffect, useRef, useState} from 'react';
|
import TWEEN from '@tweenjs/tween.js'
|
||||||
|
|
||||||
export default function useTween<T = any>(initialValue: T) {
|
export default function useTween<T = any>(initialValue: T) {
|
||||||
const [value, setValue] = useState<T>(initialValue);
|
const [value, setValue] = useState<T>(initialValue)
|
||||||
const reference = useRef<T>(value);
|
const ref = useRef<T>(value)
|
||||||
const [destination, setDestination] = useState<T>(initialValue);
|
const [dest, setDest] = useState<T>(initialValue)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function animate() {
|
function animate() {
|
||||||
requestAnimationFrame(animate);
|
requestAnimationFrame(animate)
|
||||||
TWEEN.update();
|
TWEEN.update()
|
||||||
setValue(reference.current);
|
setValue(ref.current)
|
||||||
}
|
}
|
||||||
|
|
||||||
const tween = new TWEEN.Tween(reference);
|
const tween = new TWEEN.Tween(ref)
|
||||||
tween.to({current: destination}, 1000).start();
|
tween.to({ current: dest }, 1000).start()
|
||||||
animate();
|
animate()
|
||||||
}, [destination]);
|
}, [dest])
|
||||||
|
|
||||||
return [value, setDestination] as const;
|
return [value, setDest] as const
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,31 @@
|
||||||
type I18nTable = {
|
interface I18nTable {
|
||||||
[key: string]: string | I18nTable | undefined;
|
[key: string]: string | I18nTable | undefined
|
||||||
};
|
|
||||||
|
|
||||||
export function t(
|
|
||||||
key: string,
|
|
||||||
parameters: Record<string, string> = Object.create(null) as Record<string, string>,
|
|
||||||
): string {
|
|
||||||
const segments = key.split('.');
|
|
||||||
let temporary = blessing.i18n as I18nTable | undefined;
|
|
||||||
let result = '';
|
|
||||||
|
|
||||||
for (const segment of segments) {
|
|
||||||
const middle = temporary?.[segment];
|
|
||||||
if (middle === undefined) {
|
|
||||||
return key;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof middle === 'string') {
|
|
||||||
result = middle;
|
|
||||||
} else {
|
|
||||||
temporary = middle;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const slot of Object.keys(parameters)) {
|
|
||||||
result = result.replace(`:${slot}`, parameters[slot] ?? `%{${slot}}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Object.assign(window, {trans: t});
|
export function t(key: string, parameters = Object.create(null)): string {
|
||||||
Object.assign(blessing, {t});
|
const segments = key.split('.')
|
||||||
|
let temp = blessing.i18n as I18nTable | undefined
|
||||||
|
let result = ''
|
||||||
|
|
||||||
|
for (const segment of segments) {
|
||||||
|
/* istanbul ignore next */
|
||||||
|
const middle = temp?.[segment]
|
||||||
|
if (!middle) {
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
if (typeof middle === 'string') {
|
||||||
|
result = middle
|
||||||
|
} else {
|
||||||
|
temp = middle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.keys(parameters).forEach(
|
||||||
|
(slot) => (result = result.replace(`:${slot}`, parameters[slot])),
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(window, { trans: t })
|
||||||
|
Object.assign(blessing, { t })
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
declare let __webpack_public_path__: string;
|
declare let __webpack_public_path__: string
|
||||||
declare const __blessing_public_path__: string;
|
declare const __blessing_public_path__: string
|
||||||
|
|
||||||
if (import.meta.env.NODE_ENV === 'development') {
|
if (process.env.NODE_ENV === 'development') {
|
||||||
__webpack_public_path__ = __blessing_public_path__;
|
__webpack_public_path__ = __blessing_public_path__
|
||||||
} else {
|
} else {
|
||||||
const link = document.querySelector<HTMLLinkElement>('link#cdn-host');
|
const link = document.querySelector<HTMLLinkElement>('link#cdn-host')
|
||||||
const base = link?.href ?? blessing.base_url;
|
const base = link?.href ?? blessing.base_url
|
||||||
__webpack_public_path__ = `${base}/app/`;
|
__webpack_public_path__ = `${base}/app/`
|
||||||
}
|
}
|
||||||
|
|
||||||
export {};
|
export {}
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,22 @@
|
||||||
import {t} from './i18n';
|
import { post } from './net'
|
||||||
import {post} from './net';
|
import { t } from './i18n'
|
||||||
import {showModal} from './notify';
|
import { showModal } from './notify'
|
||||||
import urls from './urls';
|
import urls from './urls'
|
||||||
|
|
||||||
export async function logout() {
|
export async function logout() {
|
||||||
try {
|
try {
|
||||||
await showModal({
|
await showModal({
|
||||||
text: t('general.confirmLogout'),
|
text: t('general.confirmLogout'),
|
||||||
center: true,
|
center: true,
|
||||||
});
|
})
|
||||||
} catch {
|
} catch {
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await post(urls.auth.logout());
|
await post(urls.auth.logout())
|
||||||
window.location.href = blessing.base_url;
|
window.location.href = blessing.base_url
|
||||||
}
|
}
|
||||||
|
|
||||||
const button = document.querySelector('#logout-button');
|
const button = document.querySelector('#logout-button')
|
||||||
|
/* istanbul ignore next */
|
||||||
button?.addEventListener('click', logout);
|
button?.addEventListener('click', logout)
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,27 @@
|
||||||
import {createRoot} from 'react-dom/client';
|
import React from 'react'
|
||||||
import Modal, {type ModalOptions, type ModalResult} from '../components/Modal';
|
import ReactDOM from 'react-dom'
|
||||||
|
import Modal, { ModalOptions, ModalResult } from '../components/Modal'
|
||||||
|
|
||||||
export async function showModal(options: ModalOptions = {}): Promise<ModalResult> {
|
export function showModal(options: ModalOptions = {}): Promise<ModalResult> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const container = document.createElement('div');
|
const container = document.createElement('div')
|
||||||
document.body.append(container);
|
document.body.appendChild(container)
|
||||||
const root = createRoot(container);
|
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
root.unmount();
|
ReactDOM.unmountComponentAtNode(container)
|
||||||
container.remove();
|
document.body.removeChild(container)
|
||||||
};
|
}
|
||||||
|
|
||||||
root.render((
|
ReactDOM.render(
|
||||||
<Modal
|
<Modal
|
||||||
{...options}
|
{...options}
|
||||||
show
|
show
|
||||||
center
|
center
|
||||||
onConfirm={resolve}
|
onConfirm={resolve}
|
||||||
onDismiss={reject}
|
onDismiss={reject}
|
||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
/>
|
/>,
|
||||||
));
|
container,
|
||||||
});
|
)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,162 +1,159 @@
|
||||||
import {emit} from './event';
|
import { emit } from './event'
|
||||||
import {t} from './i18n';
|
import { showModal } from './notify'
|
||||||
import {showModal} from './notify';
|
import { t } from './i18n'
|
||||||
|
|
||||||
export type ResponseBody<T = undefined> = {
|
export interface ResponseBody<T = null> {
|
||||||
code: number;
|
code: number
|
||||||
message: string;
|
message: string
|
||||||
data: T extends undefined ? never : T;
|
data: T extends null ? never : T
|
||||||
};
|
|
||||||
|
|
||||||
class HTTPError extends Error {
|
|
||||||
response: Response;
|
|
||||||
|
|
||||||
constructor(message: string, response: Response) {
|
|
||||||
super(message);
|
|
||||||
this.response = response;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const empty: Record<string, never> = Object.create(null);
|
class HTTPError extends Error {
|
||||||
|
response: Response
|
||||||
|
|
||||||
|
constructor(message: string, response: Response) {
|
||||||
|
super(message)
|
||||||
|
this.response = response
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const empty = Object.create(null)
|
||||||
export const init: RequestInit = {
|
export const init: RequestInit = {
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
headers: new Headers({
|
headers: new Headers({
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
}),
|
}),
|
||||||
};
|
}
|
||||||
|
|
||||||
function retrieveToken() {
|
function retrieveToken() {
|
||||||
const csrfField = document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]');
|
const csrfField = document.querySelector<HTMLMetaElement>(
|
||||||
|
'meta[name="csrf-token"]',
|
||||||
return csrfField?.content || '';
|
)
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||||
|
return csrfField?.content || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function walkFetch(request: Request): Promise<any> {
|
export async function walkFetch(request: Request): Promise<any> {
|
||||||
request.headers.set('X-CSRF-TOKEN', retrieveToken());
|
request.headers.set('X-CSRF-TOKEN', retrieveToken())
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(request);
|
const response = await fetch(request)
|
||||||
const cloned = response.clone();
|
const cloned = response.clone()
|
||||||
const body
|
const body =
|
||||||
= response.headers.get('Content-Type') === 'application/json'
|
response.headers.get('Content-Type') === 'application/json'
|
||||||
? await response.json()
|
? await response.json()
|
||||||
: await response.text();
|
: await response.text()
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
return body;
|
return body
|
||||||
}
|
}
|
||||||
|
let message: string = body.message
|
||||||
|
|
||||||
let {message} = body;
|
if (response.status === 422) {
|
||||||
|
// Process validation errors from Laravel.
|
||||||
|
const {
|
||||||
|
errors,
|
||||||
|
}: {
|
||||||
|
message: string
|
||||||
|
errors: { [field: string]: string[] }
|
||||||
|
} = body
|
||||||
|
return {
|
||||||
|
code: 1,
|
||||||
|
message: Object.keys(errors).map((field) => errors[field]![0])[0],
|
||||||
|
}
|
||||||
|
} else if (response.status === 419) {
|
||||||
|
return showModal({
|
||||||
|
mode: 'alert',
|
||||||
|
text: t('general.csrf'),
|
||||||
|
})
|
||||||
|
} else if (response.status === 403 || response.status === 400) {
|
||||||
|
return showModal({
|
||||||
|
mode: 'alert',
|
||||||
|
text: message,
|
||||||
|
type: 'warning',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (response.status === 422) {
|
if (body.exception && Array.isArray(body.trace)) {
|
||||||
// Process validation errors from Laravel.
|
const trace = (body.trace as Array<{ file: string; line: number }>)
|
||||||
const {
|
.map((t, i) => `[${i + 1}] ${t.file}#L${t.line}`)
|
||||||
errors,
|
.join('<br>')
|
||||||
}: {
|
message = `${message}<br><details>${trace}</details>`
|
||||||
message: string;
|
}
|
||||||
errors: Record<string, string[]>;
|
|
||||||
} = body;
|
|
||||||
return {
|
|
||||||
code: 1,
|
|
||||||
message: Object.keys(errors).map(field => errors[field][0])[0],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.status === 419) {
|
throw new HTTPError(message || body, cloned)
|
||||||
return await showModal({
|
} catch (error: any) {
|
||||||
mode: 'alert',
|
emit('fetchError', error)
|
||||||
text: t('general.csrf'),
|
await showModal({
|
||||||
});
|
mode: 'alert',
|
||||||
}
|
title: t('general.fatalError'),
|
||||||
|
dangerousHTML: error.message,
|
||||||
|
type: 'danger',
|
||||||
|
okButtonType: 'outline-light',
|
||||||
|
})
|
||||||
|
|
||||||
if (response.status === 403 || response.status === 400) {
|
return { code: -1, message: t('general.fatalError') }
|
||||||
return await showModal({
|
}
|
||||||
mode: 'alert',
|
|
||||||
text: message,
|
|
||||||
type: 'warning',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (body.exception && Array.isArray(body.trace)) {
|
|
||||||
const trace = (body.trace as Array<{file: string; line: number}>)
|
|
||||||
.map((t, i) => `[${i + 1}] ${t.file}#L${t.line}`)
|
|
||||||
.join('<br>');
|
|
||||||
message = `${message}<br><details>${trace}</details>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new HTTPError(message || String(body), cloned);
|
|
||||||
} catch (error: any) {
|
|
||||||
emit('fetchError', error);
|
|
||||||
await showModal({
|
|
||||||
mode: 'alert',
|
|
||||||
title: t('general.fatalError'),
|
|
||||||
dangerousHTML: error.message,
|
|
||||||
type: 'danger',
|
|
||||||
okButtonType: 'outline-light',
|
|
||||||
});
|
|
||||||
|
|
||||||
return {code: -1, message: t('general.fatalError')};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function get<T = any>(url: string, parameters: Record<string, string> | URLSearchParams = empty): Promise<T> {
|
export function get<T = any>(url: string, params = empty): Promise<T> {
|
||||||
emit('beforeFetch', {
|
emit('beforeFetch', {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
url,
|
url,
|
||||||
data: parameters,
|
data: params,
|
||||||
});
|
})
|
||||||
|
|
||||||
const qs = new URLSearchParams(parameters).toString();
|
const qs = new URLSearchParams(params).toString()
|
||||||
|
|
||||||
return walkFetch(new Request(`${blessing.base_url}${url}?${qs}`, init));
|
return walkFetch(new Request(`${blessing.base_url}${url}?${qs}`, init))
|
||||||
}
|
}
|
||||||
|
|
||||||
async function nonGet<T = any>(
|
function nonGet<T = any>(
|
||||||
method: string,
|
method: string,
|
||||||
url: string,
|
url: string,
|
||||||
data?: FormData | Record<string, unknown>,
|
data?: FormData | Record<string, unknown>,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
emit('beforeFetch', {
|
emit('beforeFetch', {
|
||||||
method: method.toUpperCase(),
|
method: method.toUpperCase(),
|
||||||
url,
|
url,
|
||||||
data,
|
data,
|
||||||
});
|
})
|
||||||
|
|
||||||
const request = new Request(`${blessing.base_url}${url}`, {
|
const request = new Request(`${blessing.base_url}${url}`, {
|
||||||
body: data instanceof FormData ? data : JSON.stringify(data),
|
body: data instanceof FormData ? data : JSON.stringify(data),
|
||||||
method: method.toUpperCase(),
|
method: method.toUpperCase(),
|
||||||
...init,
|
...init,
|
||||||
});
|
})
|
||||||
if (!(data instanceof FormData)) {
|
if (!(data instanceof FormData)) {
|
||||||
request.headers.set('Content-Type', 'application/json');
|
request.headers.set('Content-Type', 'application/json')
|
||||||
}
|
}
|
||||||
|
|
||||||
return walkFetch(request);
|
return walkFetch(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function post<T = any>(
|
export function post<T = any>(
|
||||||
url: string,
|
url: string,
|
||||||
data?: FormData | Record<string, unknown>,
|
data?: FormData | Record<string, unknown>,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
return nonGet<T>('POST', url, data);
|
return nonGet<T>('POST', url, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function put<T = any>(
|
export function put<T = any>(
|
||||||
url: string,
|
url: string,
|
||||||
data?: FormData | Record<string, unknown>,
|
data?: FormData | Record<string, unknown>,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
return nonGet<T>('PUT', url, data);
|
return nonGet<T>('PUT', url, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function del<T = any>(
|
export function del<T = any>(
|
||||||
url: string,
|
url: string,
|
||||||
data?: FormData | Record<string, unknown>,
|
data?: FormData | Record<string, unknown>,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
return nonGet<T>('DELETE', url, data);
|
return nonGet<T>('DELETE', url, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
blessing.fetch = {
|
blessing.fetch = {
|
||||||
get,
|
get,
|
||||||
post,
|
post,
|
||||||
put,
|
put,
|
||||||
del,
|
del,
|
||||||
};
|
}
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user