Compare commits

..

31 Commits
cleanup ... dev

Author SHA1 Message Date
Pig Fang
52f6fefed0
chore: remove unavailable sponsor info 2026-03-30 21:42:23 +08:00
Steven Qiu
840555df42
make tests happy 2026-03-09 23:13:47 +08:00
Steven Qiu
9aa867e8aa
refactor: do not catch exceptions when cannot read options 2026-03-09 20:21:43 +08:00
Steven Qiu
d70b39f445
feat: add Auto-Submitted header to emails
@see https://datatracker.ietf.org/doc/html/rfc3834
Hopefully this could prevent sender being spammed by auto replies...
2025-10-09 01:54:37 +08:00
SANYE-YA
33055ecbf9
fix avatar (refactor needed) (#666) 2025-08-07 05:08:13 +08:00
Steven Qiu
2e39fbce77
fix: avatar (refactor needed) 2025-07-31 19:59:29 +08:00
Steven Qiu
1b3b020d52
fix: make imagick sanitize result stable 2025-07-27 03:34:35 +08:00
Steven Qiu
33d805ee82
fix: skinlib 2d preview (refactor needed) 2025-07-26 21:38:33 +08:00
Steven Qiu
57c02dd51c
fix: check if imagick installed 2025-07-25 17:43:53 +08:00
Steven Qiu
5b6bb98860
chore: update README 2025-07-25 17:39:02 +08:00
Steven Qiu
c01112a6c1
chore: use imagick for Intervention\Image 2025-07-25 17:13:54 +08:00
Steven Qiu
064b0967fc
chore: complete Facade namespaces in use statements 2025-07-02 19:29:19 +08:00
Steven Qiu
9f4c59abec
chore: no .DS_Store [skip ci] 2025-07-02 19:29:19 +08:00
Steven Qiu
d8547a0a3d
refactor: use Intervention/Image to sanitize textures 2025-07-02 19:12:46 +08:00
Steven Qiu
9c51bd602b chore: remove redundant VSCode launch profile 2025-06-29 16:50:14 +08:00
Steven Qiu
bc3f504ca3 chore: ci 2025-06-29 16:50:14 +08:00
Steven Qiu
761cbb7828
feat: max texture width & texture sanitize (#662)
* feat: sanitize uploaded file when user upload texture

* feat: limit max texture width to avoid png bomb

* style: apply php-cs-fixer fixes

* chore: set default value for max_texture_width option

* Update skinlib.yml

Co-authored-by: Pig Fang <g-plane@hotmail.com>

---------

Co-authored-by: Pig Fang <g-plane@hotmail.com>
2025-06-29 16:09:55 +08:00
Steven Qiu
01fe3eb4cb
chore: set filename for ci snapshot build artifact 2025-06-28 17:48:16 +08:00
Steven Qiu
f03dd8122b
chore: remove redundant command in ci 2025-06-28 17:47:28 +08:00
Steven Qiu
cfda2a6bf8
style: apply php-cs-fixer fixes 2025-06-28 06:17:40 +08:00
Steven Qiu
74ce668221
fix: phpunit test 2025-06-28 06:16:49 +08:00
Steven Qiu
5a18d24464
fix: db exception in tests 2025-06-28 03:46:17 +08:00
Steven Qiu
5125862f80
fix: unexpected db query during composer install 2025-06-27 19:23:20 +08:00
Steven Qiu
fa791857ec
fix: ci snapshot build 2025-06-26 21:47:35 +08:00
Steven Qiu
24ad29ea99
style: apply php-cs-fixer fixes 2025-06-26 21:16:56 +08:00
Steven Qiu
cdfb972bd0 fix: scopes missing after cache clear 2025-06-26 21:15:53 +08:00
Zephyr Lykos
1985ce6ff8
config: switch default registry 2025-06-25 22:57:43 +08:00
Steven Qiu
d84eb65d55
Handle null route when request is handled by middleware 2025-06-22 21:50:57 +08:00
Steven Qiu
16474fb5d0
Remove locale cookie for API requests (#660)
* Do not set locale cookie for API requests

https://t.me/blessing_skin/184899

* Remove redundant code
2025-06-22 17:48:48 +08:00
Jerry
186138b884
Fix Netlify links (#656)
* Update README-zh.md

fix: wrong link

* Fix Netlify link

---------

Co-authored-by: Steven Qiu <tnqzh123@littlesk.in>
2025-06-22 17:40:50 +08:00
Zephyr Lykos
9ca6e37e39
chore: update deps 2025-01-18 16:44:33 +08:00
368 changed files with 24291 additions and 19770 deletions

View File

@ -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

View File

@ -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
View 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
View 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

View File

@ -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
View File

@ -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

View File

@ -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
View File

@ -0,0 +1,4 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
yarn pretty-quick --staged

8
.vscode/launch.json vendored
View File

@ -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",

View File

@ -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)。
## 相关链接 ## 相关链接

View File

@ -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).

View File

@ -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([

View File

@ -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',

View File

@ -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 */

View File

@ -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'))

View File

@ -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', []);

View File

@ -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]);

View File

@ -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'])],

View File

@ -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')) {

View File

@ -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'])],

View File

@ -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);

View File

@ -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();

View File

@ -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
{ {

View File

@ -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)

View File

@ -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;
} }

View File

@ -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;

View File

@ -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;

View File

@ -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]);

View File

@ -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
{ {

View File

@ -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',
]
);
}
} }

View File

@ -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',
]
);
}
} }

View File

@ -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()

View File

@ -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 arent run yet, so DB queries will fail.
* OAuth isnt 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));

View File

@ -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
{ {

View File

@ -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)

View File

@ -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();

View File

@ -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') {

View File

@ -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;

View File

@ -51,6 +51,7 @@ ini_set('display_errors', true);
'json', 'json',
'fileinfo', 'fileinfo',
'zip', 'zip',
'imagick',
], ],
'write_permission' => [ 'write_permission' => [
'bootstrap/cache', 'bootstrap/cache',

View File

@ -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

File diff suppressed because it is too large Load Diff

20
config/image.php Normal file
View 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'
];

View File

@ -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'
), ),
/* /*

View File

@ -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' => [],
],
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------

View File

@ -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
{ {

View File

@ -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
{ {

View File

@ -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
{ {

View File

@ -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
{
}
};

View File

@ -1,11 +0,0 @@
import {configBuilder} from '@mochaa/eslintrc';
export default configBuilder({
ignores: [
'public/',
'vendor/',
'vendor/',
'plugins/',
'storage/',
],
});

View File

@ -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
View File

@ -0,0 +1,5 @@
module.exports = {
plugins: [
require('autoprefixer'),
],
}

2
public/.gitignore vendored
View File

@ -1,3 +1 @@
app/ app/
build/
hot

View File

@ -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';

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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>&times;</span>
</div> </button>
) </div>
: null; ) : null
export default ModalHeader; export default ModalHeader

View File

@ -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

View File

@ -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

View File

@ -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;

View File

@ -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}
> >
&times; &times;
</button> </button>
</div> </div>
</div> </div>
); )
}; }
export default Toast; export default Toast

View File

@ -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;

View File

@ -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

View File

@ -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> }
));
}
} }

View File

@ -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()
}); })

View 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}>
&times;
</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)
}

View File

@ -0,0 +1,2 @@
rules:
'@typescript-eslint/no-empty-function': off

View 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()
}

View 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()
}

View 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()
}

View 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)
}
}

View 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')
}
}

View 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')
}
}

View 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
}

View 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}`)
}

View File

@ -0,0 +1,9 @@
export function emitKeypressEvents() {}
export function createInterface() {
return {
pause() {},
resume() {},
close() {},
}
}

View File

@ -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)
} }

View File

@ -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)
} }

View File

@ -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 }

View File

@ -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()

View File

@ -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)
} }
} }

View File

@ -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
} }

View File

@ -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')
}, []); }, [])
} }

View File

@ -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
} }

View File

@ -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
} }

View File

@ -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;
} }

View File

@ -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
} }

View File

@ -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 })

View File

@ -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 {}

View File

@ -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)

View File

@ -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,
}); )
})
} }

View File

@ -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