Compare commits

..

13 Commits
dev ... cleanup

Author SHA1 Message Date
Zephyr Lykos
baefaf51cc
cleanup: wip 6.2 2025-01-19 22:10:05 +08:00
Zephyr Lykos
d48d332c83
cleanup: wip 6.1 2025-01-19 15:25:15 +08:00
Zephyr Lykos
590ed9ce73
cleanup: wip 6 2025-01-19 14:16:55 +08:00
Zephyr Lykos
ea5be502b3
chore: update deps, use bs-community/TwigBridge 2025-01-18 17:04:51 +08:00
Zephyr Lykos
e50cc6ee28
cleanup: upgrade deps & misc 2024-04-13 16:49:28 +08:00
Zephyr Lykos
00639b7bc2
cleanup: wip 5
Use admin-lte@next, @angular-package/spectre.css
2024-03-05 11:20:32 +08:00
Zephyr Lykos
e7b4111d2b
cleanup: wip 4 2024-03-05 10:38:01 +08:00
Zephyr Lykos
643f73c752
fix: do not do type-only imports of React 2024-03-05 10:28:07 +08:00
Zephyr Lykos
e6665a3977
cleanup: wip 3.2
Move postcss config to package.json
2024-02-27 10:13:09 +08:00
Zephyr Lykos
9524a234cf
cleanup: wip 3.1
mostly misc cleanups
2024-02-24 23:01:32 +08:00
Zephyr Lykos
af2c13a8b4
cleanup: wip part 3
twig + vite integration done
needs https://github.com/rcrowe/TwigBridge/pull/435
hmr: to be tested
2024-02-24 00:53:19 +08:00
Zephyr Lykos
ae71d36c7f
cleanup: wip part 2
We have a working vite config now
2024-02-23 18:24:34 +08:00
Zephyr Lykos
2b196a95a8
cleanup: wip part 1 2024-02-23 16:58:50 +08:00
368 changed files with 19742 additions and 24263 deletions

View File

@ -9,10 +9,9 @@ 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,10 +4,11 @@ root = true
[*] [*]
charset = utf-8 charset = utf-8
end_of_line = lf end_of_line = lf
indent_size = 2 indent_size = 4
indent_style = space indent_style = tab
insert_final_newline = true insert_final_newline = true
trim_trailing_whitespace = true trim_trailing_whitespace = true
[*.{php,md,ps1,Dockerfile}] [*.{json,yaml,yml}]
indent_size = 4 indent_style = space
indent_size = 2

View File

@ -1,9 +0,0 @@
public/
vendor/
coverage/
plugins/
node_modules/
*.d.ts
resources/assets/tests/__mocks__/
resources/assets/tests/ts-shims/
resources/assets/tests/*.ts

View File

@ -1,27 +0,0 @@
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,13 +26,14 @@ jobs:
with: with:
php-version: 8.3 php-version: 8.3
coverage: none coverage: none
extensions: mbstring, dom, fileinfo, gd, imagick extensions: mbstring, dom, fileinfo, gd
- 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
@ -54,14 +55,14 @@ jobs:
with: with:
php-version: ${{ matrix.php }} php-version: ${{ matrix.php }}
coverage: none coverage: none
extensions: mbstring, dom, fileinfo, sqlite, gd, zip, imagick extensions: mbstring, dom, fileinfo, sqlite, gd, zip
- 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, imagick extensions: mbstring, dom, fileinfo, sqlite, gd, zip
- name: Cache Composer dependencies - name: Cache Composer dependencies
uses: actions/cache@v3 uses: actions/cache@v3
with: with:
@ -119,7 +120,7 @@ jobs:
with: with:
php-version: 8.2 php-version: 8.2
coverage: none coverage: none
extensions: mbstring, dom, fileinfo, sqlite, gd, zip, imagick extensions: mbstring, dom, fileinfo, sqlite, gd, zip
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Cache Node dependencies - name: Cache Node dependencies
@ -143,13 +144,11 @@ 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@v3.0 - uses: benjlevesque/short-sha@v1.2
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@v4 uses: actions/upload-artifact@v3
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,5 +25,3 @@ 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,28 +13,15 @@ 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

View File

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

8
.vscode/launch.json vendored
View File

@ -8,13 +8,17 @@
"program": "${workspaceFolder}/node_modules/.bin/jest", "program": "${workspaceFolder}/node_modules/.bin/jest",
"args": ["${file}"], "args": ["${file}"],
"internalConsoleOptions": "openOnSessionStart", "internalConsoleOptions": "openOnSessionStart",
"skipFiles": ["<node_internals>/**"] "skipFiles": [
"<node_internals>/**"
]
}, },
{ {
"type": "php", "type": "php",
"request": "launch", "request": "launch",
"name": "Launch with XDebug", "name": "Launch with XDebug",
"ignore": ["**/vendor/**/*.php"] "ignore": [
"**/vendor/**/*.php"
]
}, },
{ {
"type": "firefox", "type": "firefox",

View File

@ -52,7 +52,6 @@ Blessing Skin 对您的服务器有一定的要求。在大多数情况下,下
- JSON - JSON
- fileinfo - fileinfo
- zip - zip
- Imagick
## 快速使用 ## 快速使用
@ -62,9 +61,105 @@ 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.app/build.html)。 详情可阅读 [这里](https://blessing.netlify.com/build.html)。
> 您可以订阅我们的 Telegram 频道 [Blessing Skin News](https://t.me/blessing_skin_news) 来获取最新开发动态。当有新的 Commit 被推送时,我们的机器人将会在频道内发送一条消息来提示您能否拉取最新代码,以及拉取后应该做什么。 > 您可以订阅我们的 Telegram 频道 [Blessing Skin News](https://t.me/blessing_skin_news) 来获取最新开发动态。当有新的 Commit 被推送时,我们的机器人将会在频道内发送一条消息来提示您能否拉取最新代码,以及拉取后应该做什么。
@ -76,7 +171,7 @@ Blessing Skin 可支持多种语言,当前支持英语、简体中文和西班
## 问题报告 ## 问题报告
请参阅 [报告问题的正确姿势](https://blessing.netlify.app/report.html)。 请参阅 [报告问题的正确姿势](https://blessing.netlify.com/report.html)。
## 相关链接 ## 相关链接

View File

@ -52,7 +52,6 @@ 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
@ -62,6 +61,102 @@ 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 Illuminate\Support\Facades\Auth; use Mail;
use Illuminate\Support\Facades\Cache; use Session;
use Illuminate\Support\Facades\Mail; use URL;
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,10 +163,6 @@ 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,6 +4,7 @@ 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;
@ -11,12 +12,10 @@ 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
{ {
@ -190,7 +189,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()) {
@ -221,16 +220,6 @@ 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'),
@ -264,17 +253,8 @@ class SkinlibController extends Controller
} }
} }
$image = Image::make($file); $hash = hash_file('sha256', $file);
$imagick = $image->getCore(); $hash = $filter->apply('uploaded_texture_hash', $hash, [$file]);
$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();
@ -290,11 +270,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]);
} }
$fileSize = ceil(strlen($sanitized) / 1024); $size = ceil($file->getSize() / 1024);
$isPublic = is_string($data['public']) $isPublic = is_string($data['public'])
? $data['public'] === '1' ? $data['public'] === '1'
: $data['public']; : $data['public'];
$cost = $fileSize * ( $cost = $size * (
$isPublic $isPublic
? option('score_per_storage') ? option('score_per_storage')
: option('private_score_per_storage') : option('private_score_per_storage')
@ -305,13 +285,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', [$image, $name, $hash]); $dispatcher->dispatch('texture.uploading', [$file, $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 = $fileSize; $texture->size = $size;
$texture->public = $isPublic; $texture->public = $isPublic;
$texture->uploader = $user->uid; $texture->uploader = $user->uid;
$texture->likes = 1; $texture->likes = 1;
@ -320,14 +300,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)) {
$disk->put($hash, $sanitized); $file->storePubliclyAs('', $hash, ['disk' => 'textures']);
} }
$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, $image]); $dispatcher->dispatch('texture.uploaded', [$texture, $file]);
return json(trans('skinlib.upload.success', ['name' => $name]), 0, [ return json(trans('skinlib.upload.success', ['name' => $name]), 0, [
'tid' => $texture->tid, 'tid' => $texture->tid,
@ -406,7 +386,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',
@ -436,7 +416,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 Illuminate\Support\Facades\Cache; use Image;
use Illuminate\Support\Facades\Storage; use Storage;
use Intervention\Image\Facades\Image;
class TextureController extends Controller class TextureController extends Controller
{ {
@ -71,8 +71,7 @@ class TextureController extends Controller
$lastModified = $disk->lastModified($hash); $lastModified = $disk->lastModified($hash);
// TODO: refactor return Image::make($image)
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));
} }
@ -146,8 +145,7 @@ 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)) {
// TODO: refactor return Image::make(resource_path("misc/textures/avatar$mode.png"))
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);
} }
@ -167,8 +165,7 @@ class TextureController extends Controller
$lastModified = Carbon::createFromTimestamp($disk->lastModified($hash)); $lastModified = Carbon::createFromTimestamp($disk->lastModified($hash));
// TODO: refactor return Image::make($image)
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,9 +28,7 @@ class DetectLanguagePrefer
/** @var Response */ /** @var Response */
$response = $next($request); $response = $next($request);
if (!in_array('api', optional($request->route())->middleware() ?? [])) { $response->cookie('locale', $locale, 120);
$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,7 +41,6 @@ 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 Illuminate\Support\Facades\Event; use Event;
class NotifyFailedPlugin class NotifyFailedPlugin
{ {

View File

@ -4,7 +4,6 @@ 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
@ -27,13 +26,4 @@ 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,7 +4,6 @@ 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
@ -27,13 +26,4 @@ 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,6 +3,7 @@
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;
@ -56,17 +57,17 @@ class Player extends Model
public function user() public function user()
{ {
return $this->belongsTo(User::class, 'uid'); return $this->belongsTo(Models\User::class, 'uid');
} }
public function skin() public function skin()
{ {
return $this->belongsTo(Texture::class, 'tid_skin'); return $this->belongsTo(Models\Texture::class, 'tid_skin');
} }
public function cape() public function cape()
{ {
return $this->belongsTo(Texture::class, 'tid_cape'); return $this->belongsTo(Models\Texture::class, 'tid_cape');
} }
public function getModelAttribute() public function getModelAttribute()

View File

@ -2,10 +2,8 @@
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
@ -41,19 +39,7 @@ 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,14 +20,13 @@ class Option
return; return;
} }
if (!file_exists(storage_path('install.lock')) || app()->runningUnitTests()) { try {
$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,7 +51,6 @@ ini_set('display_errors', true);
'json', 'json',
'fileinfo', 'fileinfo',
'zip', 'zip',
'imagick',
], ],
'write_permission' => [ 'write_permission' => [
'bootstrap/cache', 'bootstrap/cache',

View File

@ -6,7 +6,6 @@
"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": "*",
@ -30,7 +29,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": "^0.14", "rcrowe/twigbridge": "dev-blessing",
"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",
@ -82,10 +81,14 @@
] ]
} }
}, },
"repositories": { "repositories": [
"packagist": { {
"type": "vcs",
"url": "https://github.com/bs-community/TwigBridge"
},
{
"type": "composer", "type": "composer",
"url": "https://packagist.org/" "url": "https://packagist.org/"
} }
} ]
} }

1831
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,20 +0,0 @@
<?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://bs-plugins.littleservice.cn/registry_{lang}.json' 'https://d2jw1l0ullrzt6.cloudfront.net/registry_{lang}.json'
), ),
/* /*

View File

@ -123,6 +123,7 @@ 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',
], ],
@ -153,7 +154,8 @@ return [
| in order to be marked as safe. | in order to be marked as safe.
| |
*/ */
'facades' => [], 'facades' => [
],
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------

View File

@ -2,7 +2,6 @@
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,8 +1,6 @@
<?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,7 +2,6 @@
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

@ -1,21 +0,0 @@
<?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
{
}
};

11
eslint.config.js Normal file
View File

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

View File

@ -1,163 +1,90 @@
{ {
"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": {
"dev": "webpack serve", "build": "vite build",
"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",
"prepare": "husky install" "dev": "vite",
"lint": "eslint .",
"test": "vitest"
}, },
"browserslist": [
"Firefox ESR",
"iOS >= 12.5",
"Chrome >= 87"
],
"dependencies": { "dependencies": {
"@emotion/react": "^11.0.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.0.0", "@emotion/styled": "^11.14.0",
"@fortawesome/fontawesome-free": "^6.3.0", "@fortawesome/fontawesome-free": "^6.7.2",
"@hot-loader/react-dom": "^17.0.0", "@tweenjs/tween.js": "^25.0.0",
"@tweenjs/tween.js": "^18.5.0", "admin-lte": "4.0.0-beta3",
"admin-lte": "^3.2.0", "bootstrap": "^5.3.3",
"blessing-skin-shell": "^0.3.4", "clsx": "^2.1.1",
"bootstrap": "^4.6.1", "downshift": "^9.0.8",
"cac": "6.6.1", "echarts": "^5.6.0",
"cli-spinners": "^2.5.0", "immer": "^10.1.1",
"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.debounce": "^4.0.8", "lodash-es": "^4.0.8",
"nanoid": "^3.1.9", "nanoid": "^5.0.9",
"prompts": "^2.4.0", "prompts": "^2.4.0",
"react": "^17.0.1", "react": "^18.0.0",
"react-autosuggest": "^10.0.2", "react-dom": "^18.0.0",
"react-dom": "^17.0.1",
"react-draggable": "^4.4.2", "react-draggable": "^4.4.2",
"react-hot-loader": "^4.12.21", "react-loading-skeleton": "^3.5.0",
"react-loading-skeleton": "^2.1.1", "react-use": "^17.6.0",
"react-use": "^17.4.0",
"reaptcha": "^1.7.2", "reaptcha": "^1.7.2",
"rxjs": "^6.5.5", "rxjs": "^7.8.1",
"skinview-utils": "^0.5.5", "skinview-utils": "^0.7.1",
"skinview3d": "^3.0.0-alpha.1", "skinview3d": "^3.1.0",
"spectre.css": "^0.5.8", "spectre.css": "github:angular-package/spectre.css",
"use-immer": "^0.4.2", "use-immer": "^0.11.0"
"xterm": "^4.6.0",
"xterm-addon-fit": "^0.4.0"
}, },
"devDependencies": { "devDependencies": {
"@gplane/tsconfig": "^4.2.0", "@eslint-react/eslint-plugin": "^1.23.2",
"@testing-library/jest-dom": "^5.11.10", "@mochaa/eslintrc": "^0.1.12",
"@testing-library/react": "^11.2.6", "@testing-library/jest-dom": "^6.6.3",
"@types/bootstrap": "^4.3.3", "@testing-library/react": "^16.2.0",
"@types/css-minimizer-webpack-plugin": "^1.1.0", "@tsconfig/vite-react": "^3.4.0",
"@types/jest": "^26.0.23", "@types/bootstrap": "^5.2.10",
"@types/jquery": "^3.5.13", "@types/jquery": "^3.5.32",
"@types/js-yaml": "^3.12.4", "@types/js-yaml": "^4.0.9",
"@types/lodash.debounce": "^4.0.6", "@types/lodash-es": "^4.0.6",
"@types/mini-css-extract-plugin": "^1.2.1",
"@types/prompts": "^2.0.9", "@types/prompts": "^2.0.9",
"@types/react": "^16.9.35", "@types/react": "^18",
"@types/react-autosuggest": "^9.3.14", "@types/react-dom": "^18",
"@types/react-dom": "^16.9.8",
"@types/tween.js": "^18.5.0", "@types/tween.js": "^18.5.0",
"@types/webpack-dev-server": "^3.11.0", "@vitejs/plugin-react-swc": "^3.7.2",
"@typescript-eslint/eslint-plugin": "^3.6.0", "autoprefixer": "^10.4.20",
"@typescript-eslint/parser": "^3.6.0", "browserslist": "^4.24.4",
"autoprefixer": "^10.2.6", "browserslist-to-esbuild": "^2.1.1",
"css-loader": "^5.2.6", "eslint": "^9.18.0",
"css-minimizer-webpack-plugin": "^3.0.1", "eslint-plugin-react-hooks": "^5.1.0",
"eslint": "^7.4.0", "eslint-plugin-react-refresh": "^0.4.18",
"eslint-formatter-beauty": "^3.0.0", "js-yaml": "^4.1.0",
"eslint-plugin-react-hooks": "^4.3.0", "laravel-vite-plugin": "^1.1.1",
"html-webpack-plugin": "^5.3.1", "postcss": "^8.5.1",
"husky": "^7.0.4", "sass": "^1.83.4",
"jest": "^27.0.4", "typescript": "^5.7.3",
"jest-extended": "^0.11.5", "vite": "^6.0.7",
"js-yaml": "^3.13.1", "vitest": "^3.0.2"
"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"
}, },
"browserslist": [ "postcss": {
"> 1%", "plugins": {
"not dead", "autoprefixer": {}
"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"
} }

View File

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

2
public/.gitignore vendored
View File

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

View File

@ -0,0 +1,6 @@
@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 @@
import React from 'react' type AlertType = 'success' | 'info' | 'warning' | 'danger';
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'],
]) ]);
interface Props { type Props = {
type: AlertType readonly type: AlertType;
} readonly children?: React.ReactNode;
};
const Alert: React.FC<Props> = (props) => { const Alert: React.FC<Props> = ({type, children}) => {
const { type } = props const icon = icons.get(type);
const icon = icons.get(type)
return props.children ? ( return children === ''
<div className={`alert alert-${type}`}> ? null
<i className={`icon fas fa-${icon}`}></i> : (
{props.children} <div className={`alert alert-${type}`}>
</div> <i className={`icon fas fa-${icon}`}/>
) : null {children}
} </div>
);
};
export default Alert export default Alert;

View File

@ -1,14 +1,12 @@
import React from 'react' type Props = {
readonly title?: string;
readonly onClick: React.MouseEventHandler<HTMLAnchorElement>;
};
interface Props { const ButtonEdit: React.FC<Props> = ({title, onClick}) => (
title?: string <a href='#' title={title} className='ml-2' onClick={onClick}>
onClick: React.MouseEventHandler<HTMLAnchorElement> <i className='fas fa-edit'/>
} </a>
);
const ButtonEdit: React.FC<Props> = (props) => ( export default ButtonEdit;
<a href="#" title={props.title} className="ml-2" onClick={props.onClick}>
<i className="fas fa-edit"></i>
</a>
)
export default ButtonEdit

View File

@ -1,103 +1,108 @@
/** @jsxImportSource @emotion/react */ import {emit, on} from '@/scripts/event';
import * as React from 'react' import {t} from '@/scripts/i18n';
import Reaptcha from 'reaptcha' import * as cssUtils from '@/styles/utils';
import { emit, on } from '@/scripts/event' import React from 'react';
import { t } from '@/scripts/i18n' import Reaptcha from 'reaptcha';
import * as cssUtils from '@/styles/utils'
const eventId = Symbol() const eventId = Symbol('EventId');
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> { class Captcha extends React.Component<Record<string, unknown>, State> {
state: State state: State;
ref: React.MutableRefObject<Reaptcha | null> // eslint-disable-next-line ts/no-restricted-types
ref: React.RefObject<Reaptcha | null>;
constructor(props: Record<string, unknown>) { constructor(props: Record<string, unknown>) {
super(props) super(props);
this.state = { this.state = {
value: '', value: '',
time: Date.now(), time: Date.now(),
sitekey: blessing.extra.recaptcha, sitekey: blessing.extra.recaptcha as string,
invisible: blessing.extra.invisible, invisible: blessing.extra.invisible as boolean,
} };
this.ref = React.createRef() this.ref = React.createRef<Reaptcha>();
} }
execute = async () => { // eslint-disable-next-line react/no-unused-class-component-members
const recaptcha = this.ref.current async execute() {
if (recaptcha && this.state.invisible) { const recaptcha = this.ref.current;
return new Promise<string>((resolve) => { if (recaptcha && this.state.invisible) {
const off = on(eventId, (value: string) => { return new Promise<string>(resolve => {
resolve(value) const off = on(eventId, (value: string) => {
off() resolve(value);
}) off();
recaptcha.execute() });
}) void recaptcha.execute();
} });
return this.state.value }
}
reset = () => { return this.state.value;
const recaptcha = this.ref.current }
if (recaptcha) {
recaptcha.reset()
} else {
this.setState({ time: Date.now() })
}
}
handleValueChange = (event: React.ChangeEvent<HTMLInputElement>) => { // eslint-disable-next-line react/no-unused-class-component-members
this.setState({ value: event.target.value }) reset() {
} const recaptcha = this.ref.current;
if (recaptcha) {
void recaptcha.reset();
} else {
this.setState({time: Date.now()});
}
}
handleVerify = (value: string) => { handleValueChange = (event: React.ChangeEvent<HTMLInputElement>) => {
emit(eventId, value) this.setState({value: event.target.value});
this.setState({ value }) };
}
handleRefresh = () => { handleVerify = (value: string) => {
this.setState({ time: Date.now() }) emit(eventId, value);
} this.setState({value});
};
render() { handleRefresh = () => {
return this.state.sitekey ? ( this.setState({time: Date.now()});
<div className="mb-2"> };
<Reaptcha
ref={this.ref} render() {
sitekey={this.state.sitekey} return this.state.sitekey
size={this.state.invisible ? 'invisible' : 'normal'} ? (
onVerify={this.handleVerify} <div className='mb-2'>
/> <Reaptcha
</div> ref={this.ref}
) : ( sitekey={this.state.sitekey}
<div className="d-flex"> size={this.state.invisible ? 'invisible' : 'normal'}
<div className="form-group mb-3 mr-2"> onVerify={this.handleVerify}
<input />
type="text" </div>
className="form-control" )
placeholder={t('auth.captcha')} : (
required <div className='d-flex'>
value={this.state.value} <div className='form-group mb-3 mr-2'>
onChange={this.handleValueChange} <input
/> required
</div> type='text'
<img className='form-control'
src={`${blessing.base_url}/auth/captcha?v=${this.state.time}`} placeholder={t('auth.captcha')}
alt={t('auth.captcha')} value={this.state.value}
css={cssUtils.pointerCursor} onChange={this.handleValueChange}
height={34} />
title={t('auth.change-captcha')} </div>
onClick={this.handleRefresh} <img
/> src={`${blessing.base_url}/auth/captcha?v=${this.state.time}`}
</div> alt={t('auth.captcha')}
) css={cssUtils.pointerCursor}
} height={34}
title={t('auth.change-captcha')}
onClick={this.handleRefresh}
/>
</div>
);
}
} }
export default Captcha export default Captcha;

View File

@ -1,27 +1,27 @@
import React, { useState } from 'react' import * as fetch from '@/scripts/net';
import * as fetch from '@/scripts/net' import {useState} from 'react';
interface Props { type Props = {
initMode: boolean readonly 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> <i className={`fas fa-${icon}`}/>
</a> </a>
) );
} };
export default DarkModeButton export default DarkModeButton;

View File

@ -1,89 +1,67 @@
/** @jsxImportSource @emotion/react */ import {emit} from '@/scripts/event';
import React, { useState, useEffect } from 'react' import {pointerCursor} from '@/styles/utils';
import Autosuggest from 'react-autosuggest' import {css} from '@emotion/react';
import { css } from '@emotion/react' import clsx from 'clsx';
import { emit } from '@/scripts/event' import {useCombobox} from 'downshift';
import { pointerCursor } from '@/styles/utils' import {useEffect, useState} from 'react';
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<Autosuggest.InputProps<string>, 'onChange'> & { type Props = Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange'> & {
onChange(value: string): void onChange: (value: string) => void;
} };
const EmailSuggestion: React.FC<Props> = (props) => { const EmailSuggestion: React.FC<Props> = props => {
const [suggestions, setSuggestions] = useState<string[]>([]) useEffect(() => {
emit('emailDomainsSuggestion', domainNames);
}, []);
const [inputItems, setInputItems] = useState<string[]>([]);
useEffect(() => { const {
emit('emailDomainsSuggestion', domainNames) isOpen,
}, []) 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 handleSuggestionsFetchRequested: Autosuggest.SuggestionsFetchRequested = const {onChange} = props;
({ value }) => { onChange(value);
const segments = value.split('@') },
setSuggestions([...domainNames].map((name) => `${segments[0]}@${name}`)) });
}
const handleSuggestionsClearRequested = () => { return (
setSuggestions([]) <div>
} <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>
);
};
const shouldRenderSuggestions = (value: string) => { export default EmailSuggestion;
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,53 +1,52 @@
/** @jsxImportSource @emotion/react */ import {t} from '@/scripts/i18n';
import { useRef } from 'react' import {css} from '@emotion/react';
import { css } from '@emotion/react' import {useRef} from 'react';
import { t } from '@/scripts/i18n'
const hideRawBrowseButton = css` const hideRawBrowseButton = css`
::after { ::after {
display: none; display: none;
} }
` `;
interface Props { type Props = {
file: File | null file: File | undefined;
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 ref = useRef<HTMLInputElement>(null) const reference = useRef<HTMLInputElement>(null);
const handleClick = () => { const handleClick = () => {
ref.current!.click() reference.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
type="file" ref={reference}
className="custom-file-input" type='file'
id="select-file" className='custom-file-input'
accept={props.accept} id='select-file'
title={t('skinlib.upload.select-file')} accept={props.accept}
ref={ref} title={t('skinlib.upload.select-file')}
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 @@
import React from 'react' function Loading() {
return (
<div className='container text-center' title='Loading...'>
<i className='fas fa-sync fa-spin'/>
</div>
);
}
const Loading = () => ( export default Loading;
<div className="container text-center" title="Loading...">
<i className="fas fa-sync fa-spin"></i>
</div>
)
export default Loading

View File

@ -1,165 +1,193 @@
import React, { useState, useEffect, useRef } from 'react' import {Modal as BootstrapModal} from 'bootstrap';
import $ from 'jquery' import clsx from 'clsx';
import 'bootstrap' import {useEffect, useRef, useState} from 'react';
import { t } from '../scripts/i18n' import {t} from '../scripts/i18n';
import ModalHeader from './ModalHeader' import ModalBody, {type Props as BodyProps} from './ModalBody';
import ModalBody from './ModalBody' import ModalFooter, {type Props as FooterProps} from './ModalFooter';
import ModalFooter from './ModalFooter' import ModalHeader, {type Props as HeaderProps} from './ModalHeader';
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 = {
mode?: 'alert' | 'confirm' | 'prompt' readonly mode?: 'alert' | 'confirm' | 'prompt';
show?: boolean readonly show?: boolean;
input?: string readonly input?: string;
validator?(value: any): string | boolean | undefined validator?: (value: any) => string | boolean | undefined;
type?: string readonly type?: string;
showHeader?: boolean readonly showHeader?: boolean;
center?: boolean readonly 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 = {
id?: string readonly id?: string;
children?: React.ReactNode readonly children?: React.ReactNode;
footer?: React.ReactNode readonly 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,
} = props footer,
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 ref = useRef<HTMLDivElement>(null) const reference = useRef<HTMLDivElement>(null);
const [modal, setModal] = useState<BootstrapModal>();
const { show } = props useEffect(() => {
if (!reference.current) {
return;
}
useEffect(() => { const _modal = new BootstrapModal(reference.current);
if (!show) { setModal(_modal);
return
}
const onHidden = () => props.onClose?.() return () => {
_modal.dispose();
};
}, [reference]);
const el = $(ref.current!) useEffect(() => {
el.on('hidden.bs.modal', onHidden) if (!show) {
return;
}
return () => { const onHidden = () => {
el.off('hidden.bs.modal', onHidden) onClose?.();
} };
}, [show, props.onClose])
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => { const element = reference.current;
setValue(event.target.value) if (!element) {
} return;
}
const confirm = () => { element.addEventListener('hidden.bs.modal', onHidden);
const { validator } = props
if (typeof validator === 'function') {
const result = validator(value)
if (typeof result === 'string') {
setValidatorMessage(result)
setValid(false)
return
}
}
props.onConfirm?.({ value }) return () => {
$(ref.current!).modal('hide') element.removeEventListener('hidden.bs.modal', onHidden);
};
}, [reference, show, onClose]);
// The "hidden.bs.modal" event can't be trigged automatically when testing. const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
/* istanbul ignore next */ setValue(event.target.value);
if (process.env.NODE_ENV === 'test') { };
$(ref.current!).trigger('hidden.bs.modal')
}
}
const dismiss = () => { const confirm = () => {
props.onDismiss?.() if (typeof validator === 'function') {
$(ref.current!).modal('hide') const result = validator(value);
if (typeof result === 'string') {
setValidatorMessage(result);
setValid(false);
return;
}
}
/* istanbul ignore next */ onConfirm?.({value});
if (process.env.NODE_ENV === 'test') { modal?.hide();
$(ref.current!).trigger('hidden.bs.modal')
}
}
useEffect(() => { // The "hidden.bs.modal" event can't be trigged automatically when testing.
if (show) {
setTimeout(() => $(ref.current!).modal('show'), 50)
}
}, [show])
if (!show) { if (import.meta.env.NODE_ENV === 'test') {
return null $(reference.current!).trigger('hidden.bs.modal');
} }
};
return ( const dismiss = () => {
<div id={props.id} className="modal fade" role="dialog" ref={ref}> onDismiss?.();
<div modal?.hide();
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>
)
}
export default Modal if (import.meta.env.NODE_ENV === 'test') {
$(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,29 +1,25 @@
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'
interface InternalProps { import ModalContent, {type Props as ContentProps} from './ModalContent';
showInput: boolean import ModalInput, {
} type
InternalProps as InputInteralProps,
type
Props as InputProps,
} from './ModalInput';
export type Props = ContentProps & InputProps type InternalProps = {
readonly showInput: boolean;
};
const ModalBody: React.FC<InternalProps & InputInteralProps & Props> = ( export type Props = ContentProps & InputProps;
props,
) => {
return (
<div className="modal-body">
<ModalContent text={props.text} dangerousHTML={props.dangerousHTML}>
{props.children}
</ModalContent>
{props.showInput && <ModalInput {...props} />}
</div>
)
}
export default ModalBody const ModalBody: React.FC<InternalProps & InputInteralProps & Props> = props => (
<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,26 +1,29 @@
import React from 'react'
export interface Props { export type Props = {
text?: string readonly text?: string;
dangerousHTML?: string readonly 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 }} />
}
return <></> if (props.text) {
} return (
<>
{props.text.split(/\r?\n/).map((line, i) =>
<p key={i}>{line}</p>)}
</>
);
}
export default ModalContent if (props.dangerousHTML) {
return <div dangerouslySetInnerHTML={{__html: props.dangerousHTML}}/>;
}
return <></>;
};
export default ModalContent;

View File

@ -1,49 +1,50 @@
import React from 'react'
export interface Props { export type Props = {
flexFooter?: boolean readonly flexFooter?: boolean;
okButtonText?: string readonly okButtonText?: string;
okButtonType?: string readonly okButtonType?: string;
cancelButtonText?: string readonly cancelButtonText?: string;
cancelButtonType?: string readonly cancelButtonType?: string;
} readonly children?: React.ReactNode;
};
interface InternalProps { type InternalProps = {
showCancelButton: boolean readonly 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(' ')
return props.children ? ( const footerClass = classes.join(' ');
<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 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>
);
};
export default ModalFooter;

View File

@ -1,28 +1,27 @@
import React from 'react'
export interface Props { export type Props = {
title?: string readonly title?: string;
} };
interface InternalProps { type InternalProps = {
onDismiss?(): void onDismiss?: () => void;
show?: boolean readonly show?: boolean;
} };
const ModalHeader: React.FC<Props & InternalProps> = (props) => const ModalHeader: React.FC<Props & InternalProps> = ({show, title, onDismiss}) =>
props.show ? ( show
<div className="modal-header"> ? (
<h5 className="modal-title">{props.title}</h5> <div className='modal-header'>
<button <h5 className='modal-title'>{title}</h5>
type="button" <button
className="close" type='button'
data-dismiss="modal" className='btn-close'
aria-label="Close" data-bs-dismiss='modal'
onClick={props.onDismiss} aria-label='Close'
> onClick={onDismiss}
<span aria-hidden>&times;</span> />
</button> </div>
</div> )
) : null : null;
export default ModalHeader export default ModalHeader;

View File

@ -1,58 +1,59 @@
import React, { HTMLAttributes } from 'react'
export interface Props { export type Props = {
inputType?: string readonly inputType?: string;
inputMode?: HTMLAttributes<HTMLInputElement>['inputMode'] readonly inputMode?: React.HTMLAttributes<HTMLInputElement>['inputMode'];
choices?: { text: string; value: string }[] readonly choices?: Array<{text: string; value: string}>;
placeholder?: string readonly placeholder?: string;
} };
export interface InternalProps { export type InternalProps = {
value?: string readonly value?: string;
invalid?: boolean readonly invalid?: boolean;
validatorMessage?: string readonly validatorMessage?: string;
onChange?: React.ChangeEventHandler<HTMLInputElement> readonly 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) => ( <>
<div key={choice.value}> {props.choices.map(choice => (
<input <div key={choice.value}>
type="radio" <input
name="modal-radios" type='radio'
id={`modal-radio-${choice.value}`} name='modal-radios'
value={choice.value} id={`modal-radio-${choice.value}`}
checked={choice.value === props.value} value={choice.value}
onChange={props.onChange} checked={choice.value === props.value}
/> onChange={props.onChange}
<label htmlFor={`modal-radio-${choice.value}`} className="ml-1"> />
{choice.text} <label htmlFor={`modal-radio-${choice.value}`} className='ml-1'>
</label> {choice.text}
</div> </label>
))} </div>
</> ))}
) : ( </>
<div className="form-group"> )
<input : (
value={props.value} <div className='form-group'>
onChange={props.onChange} <input
type={props.inputType} value={props.value}
inputMode={props.inputMode} type={props.inputType}
className="form-control" inputMode={props.inputMode}
placeholder={props.placeholder} className='form-control'
></input> placeholder={props.placeholder}
</div> onChange={props.onChange}
)} />
{props.invalid && ( </div>
<div className="alert alert-danger"> )}
<i className="icon far fa-times-circle"></i> {props.invalid && (
<span className="ml-1">{props.validatorMessage}</span> <div className='alert alert-danger'>
</div> <i className='icon far fa-times-circle'/>
)} <span className='ml-1'>{props.validatorMessage}</span>
</> </div>
) )}
</>
);
export default ModalInput export default ModalInput;

View File

@ -1,124 +1,122 @@
import React from 'react'
import { t } from '@/scripts/i18n'
import PaginationItem from './PaginationItem'
interface Props { import {t} from '@/scripts/i18n';
page: number import PaginationItem from './PaginationItem';
totalPages: number
onChange(page: number): void | Promise<void> type Props = {
} 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={() => onChange(page - 1)} onClick={async () => 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={() => onChange(i + 1)} onClick={async () => 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={() => onChange(n)} onClick={async () => onChange(n)}
> >
{n} {n}
</PaginationItem> </PaginationItem>
)) ))
) : ( : (
<PaginationItem <PaginationItem
className="d-none d-sm-block" className='d-none d-sm-block'
onClick={() => onChange(1)} onClick={async () => onChange(1)}
> >
1 1
</PaginationItem> </PaginationItem>
)} )}
<PaginationItem className="d-none d-sm-block" disabled> <PaginationItem disabled className='d-none d-sm-block'>
... ...
</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={() => onChange(n)} onClick={async () => onChange(n)}
> >
{n} {n}
</PaginationItem> </PaginationItem>
))} ))}
<PaginationItem className="d-none d-sm-block" disabled> <PaginationItem disabled className='d-none d-sm-block'>
... ...
</PaginationItem> </PaginationItem>
</> </>
)} )}
{totalPages - page < 3 ? ( {totalPages - page < 3
[totalPages - 3, totalPages - 2, totalPages - 1, totalPages].map( ? [totalPages - 3, totalPages - 2, totalPages - 1, totalPages].map(n => (
(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
) : ( className='d-none d-sm-block'
<PaginationItem onClick={async () => onChange(totalPages)}
className="d-none d-sm-block" >
onClick={() => onChange(totalPages)} {totalPages}
> </PaginationItem>
{totalPages} )}
</PaginationItem> </>
)} )}
</> <PaginationItem
)} title={t('vendor.datatable.next')}
<PaginationItem disabled={page === totalPages}
title={t('vendor.datatable.next')} onClick={async () => onChange(page + 1)}
disabled={page === totalPages} >
onClick={() => onChange(page + 1)} <span className='d-inline d-sm-none mr-1'>
> {t('vendor.datatable.next')}
<span className="d-inline d-sm-none mr-1"> </span>
{t('vendor.datatable.next')} {labels.next}
</span> </PaginationItem>
{labels.next} </ul>
</PaginationItem> );
</ul> };
)
}
export default Pagination export default Pagination;

View File

@ -1,39 +1,41 @@
import React from 'react'
interface Props { type Props = {
disabled?: boolean readonly disabled?: boolean;
active?: boolean readonly active?: boolean;
title?: string readonly title?: string;
className?: string readonly 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)
}
const handleClick = (event: React.MouseEvent) => { if (props.disabled) {
event.preventDefault() classes.push('disabled');
if (!props.disabled && props.onClick) { }
props.onClick()
}
}
return ( if (props.className) {
<li className={classes.join(' ')} title={props.title} onClick={handleClick}> classes.push(props.className);
<a href="#" className="page-link" aria-disabled={props.disabled}> }
{props.children}
</a>
</li>
)
}
export default PaginationItem const handleClick = (event: React.MouseEvent) => {
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 @@
/** @jsxImportSource @emotion/react */ import {css} from '@emotion/react';
import React, { useState, useEffect } from 'react' import {useEffect, useState} from 'react';
import { css } from '@emotion/react'
export type ToastType = 'success' | 'info' | 'warning' | 'error' export type ToastType = 'success' | 'info' | 'warning' | 'error';
interface Props { type Props = {
type: ToastType readonly type: ToastType;
distance: number readonly 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,52 +24,54 @@ 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(() => setShow(true), 100) const timer = setTimeout(() => {
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> <i className={`icon fas fa-${icons.get(props.type)}`}/>
</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 @@
/** @jsxImportSource @emotion/react */ import {t} from '@/scripts/i18n';
import React, { useState, useEffect, useRef } from 'react' import * as breakpoints from '@/styles/breakpoints';
import { useMeasure } from 'react-use' import * as cssUtils from '@/styles/utils';
import { css } from '@emotion/react' import {css} from '@emotion/react';
import styled from '@emotion/styled' import styled from '@emotion/styled';
import * as skinview3d from 'skinview3d' import {useEffect, useRef, useState} from 'react';
import { t } from '@/scripts/i18n' import {useMeasure} from 'react-use';
import * as cssUtils from '@/styles/utils' import * as skinview3d from 'skinview3d';
import * as breakpoints from '@/styles/breakpoints' import bg1 from '../../../misc/backgrounds/1.webp';
import SkinSteve from '../../../misc/textures/steve.png' import bg2 from '../../../misc/backgrounds/2.webp';
import bg1 from '../../../misc/backgrounds/1.webp' import bg3 from '../../../misc/backgrounds/3.webp';
import bg2 from '../../../misc/backgrounds/2.webp' import bg4 from '../../../misc/backgrounds/4.webp';
import bg3 from '../../../misc/backgrounds/3.webp' import bg5 from '../../../misc/backgrounds/5.webp';
import bg4 from '../../../misc/backgrounds/4.webp' import bg6 from '../../../misc/backgrounds/6.webp';
import bg5 from '../../../misc/backgrounds/5.webp' import bg7 from '../../../misc/backgrounds/7.webp';
import bg6 from '../../../misc/backgrounds/6.webp' import SkinSteve from '../../../misc/textures/steve.png';
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;
interface Props { type Props = {
skin?: string readonly skin?: string;
cape?: string readonly cape?: string;
isAlex: boolean readonly children?: React.ReactNode;
showIndicator?: boolean readonly isAlex: boolean;
initPositionZ?: number readonly showIndicator?: boolean;
} 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,251 +56,255 @@ 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 viewRef: React.MutableRefObject<skinview3d.SkinViewer> = useRef(null!) const viewReference: React.MutableRefObject<skinview3d.SkinViewer> = useRef(null!);
const containerRef = useRef<HTMLCanvasElement>(null) const containerReference = 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 ''
})()
useEffect(() => { if (skin) {
const container = containerRef.current! return t('general.skin');
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 (document.body.classList.contains('dark-mode')) { if (cape) {
viewer.background = '#6c757d' return t('general.cape');
} }
viewRef.current = viewer return '';
})();
return () => { useEffect(() => {
viewer.dispose() const container = containerReference.current!;
} const viewer = new skinview3d.SkinViewer({
// eslint-disable-next-line react-hooks/exhaustive-deps 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;
const [containerWrapperRef, containerMeasure] = useMeasure<HTMLDivElement>() if (document.body.classList.contains('dark-mode')) {
useEffect(() => { viewer.background = '#6c757d';
viewRef.current.setSize(containerMeasure.width, containerMeasure.height) }
})
useEffect(() => { viewReference.current = viewer;
const viewer = viewRef.current
viewer.loadSkin(props.skin || SkinSteve, {
model: props.isAlex ? 'slim' : 'default',
})
}, [props.skin, props.isAlex])
useEffect(() => { return () => {
const viewer = viewRef.current viewer.dispose();
if (props.cape) { };
viewer.loadCape(props.cape) // eslint-disable-next-line react-hooks/exhaustive-deps
} else { }, []);
viewer.resetCape()
}
}, [props.cape])
useEffect(() => { const [containerWrapperReference, containerMeasure] = useMeasure<HTMLDivElement>();
const viewer = viewRef.current useEffect(() => {
const factory = animationFactories[animation] viewReference.current.setSize(containerMeasure.width, containerMeasure.height);
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 currentAnimation = viewRef.current.animation const viewer = viewReference.current;
if (currentAnimation !== null) { viewer.loadSkin(props.skin || SkinSteve, {
currentAnimation.paused = paused model: props.isAlex ? 'slim' : 'default',
} });
}, [paused]) }, [props.skin, props.isAlex]);
useEffect(() => { useEffect(() => {
const viewer = viewRef.current const viewer = viewReference.current;
const backgroundUrl = backgrounds[bgPicture] if (props.cape) {
if (backgroundUrl === undefined) { viewer.loadCape(props.cape);
viewer.background = null } else {
} else { viewer.resetCape();
viewer.loadBackground(backgroundUrl) }
} }, [props.cape]);
}, [bgPicture])
const togglePause = () => { useEffect(() => {
setPaused((paused) => { const viewer = viewReference.current;
if (paused) { const factory = animationFactories[animation];
return false if (factory === undefined) {
} else { viewer.animation = null;
viewRef.current.autoRotate = false } else {
return true const newAnimation = factory();
} newAnimation.paused = paused; // Perseve `paused` state
}) viewer.animation = newAnimation;
} }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [animation]);
const toggleAnimation = () => { useEffect(() => {
setAnimation((index) => (index + 1) % animationFactories.length) const currentAnimation = viewReference.current.animation;
setPaused(false) if (currentAnimation !== null) {
} currentAnimation.paused = paused;
}
}, [paused]);
const toggleRotate = () => { useEffect(() => {
const viewer = viewRef.current const viewer = viewReference.current;
viewer.autoRotate = !viewer.autoRotate const backgroundUrl = backgrounds[bgPicture];
} if (backgroundUrl === undefined) {
viewer.background = null;
} else {
viewer.loadBackground(backgroundUrl);
}
}, [bgPicture]);
const toggleBackEquippment = () => { const togglePause = () => {
const player = viewRef.current.playerObject setPaused(paused => {
if (player.backEquipment === 'cape') { if (paused) {
player.backEquipment = 'elytra' return false;
} else { }
player.backEquipment = 'cape'
}
}
const setWhite = () => { viewReference.current.autoRotate = false;
viewRef.current.background = '#fff' return true;
} });
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
}
})
}
return ( const toggleAnimation = () => {
<div className="card"> setAnimation(index => (index + 1) % animationFactories.length);
<div className="card-header"> setPaused(false);
<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>
)
}
export default Viewer const toggleRotate = () => {
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 React from 'react' import {t} from '@/scripts/i18n';
import { t } from '@/scripts/i18n'
const ViewerSkeleton: React.FC = () => ( export default function ViewerSkeleton() {
<div className="card"> return (
<div className="card-header"> <div className='card'>
<div className="d-flex justify-content-between"> <div className='card-header'>
<h3 className="card-title"> <div className='d-flex justify-content-between'>
<span>{t('general.texturePreview')}</span> <h3 className='card-title'>
</h3> <span>{t('general.texturePreview')}</span>
</div> </h3>
</div> </div>
<div className="card-body"></div> </div>
</div> <div className='card-body'/>
) </div>
);
}
export default ViewerSkeleton

View File

@ -1,5 +1,5 @@
@import '../fonts/minecraft.css'; @import '@/fonts/minecraft.css';
@import './avatar.css'; @import '@/styles/avatar.css';
body { body {
font-size: 16px; font-size: 16px;

View File

@ -1,37 +1,35 @@
import * as React from 'react' import $ from 'jquery';
import * as ReactDOM from 'react-dom' import React from 'react';
import $ from 'jquery' import ReactDOM from 'react-dom';
import './scripts/app' import {createRoot} from 'react-dom/client';
import routes from './scripts/route' import routes from './scripts/route';
Object.assign(window, { React, ReactDOM, $ }) import './scripts/app';
const entry = document.querySelector('[href="#launch-cli"]') // eslint-disable-next-line ts/naming-convention
entry?.addEventListener('click', async () => { Object.assign(window, {React, ReactDOM, $});
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) {
Promise.all(route.module.map((m) => m())) void Promise.all(route.module.map(async m => m()));
} }
if (route.react) {
const Component = React.lazy( if (route.react) {
route.react as () => Promise<{ default: React.ComponentType }>, const Component = React.lazy(route.react as () => Promise<{default: React.ComponentType}>);
)
const Root = () => ( const container = typeof route.el === 'string'
<React.StrictMode> ? document.querySelector(route.el)
<React.Suspense fallback={route.frame?.() ?? ''}> : null;
<Component />
</React.Suspense> const root = createRoot(container!);
</React.StrictMode> root.render((
) <React.StrictMode>
const c = <React.Suspense fallback={route.frame?.() ?? ''}>
typeof route.el === 'string' ? document.querySelector(route.el) : route.el <Component/>
ReactDOM.render(<Root />, c) </React.Suspense>
} </React.StrictMode>
));
}
} }

View File

@ -1,14 +1,15 @@
import './init' // must be first import {Tooltip} from 'bootstrap';
import 'admin-lte' import '@popperjs/core';
import './extra' import 'admin-lte';
import './i18n' import './extra';
import './net' import './i18n';
import './event' import './net';
import './notification' import './event';
import './emailVerification' import './notification';
import './logout' import './emailVerification';
import './darkMode' import './logout';
import './darkMode';
window.addEventListener('load', () => { window.addEventListener('load', () => {
$('[data-toggle="tooltip"]').tooltip() [...document.querySelectorAll('[data-toggle="tooltip"]')].map(el => new Tooltip(el));
}) });

View File

@ -1,140 +0,0 @@
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

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

View File

@ -1,23 +0,0 @@
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

@ -1,46 +0,0 @@
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

@ -1,23 +0,0 @@
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

@ -1,31 +0,0 @@
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

@ -1,40 +0,0 @@
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

@ -1,28 +0,0 @@
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

@ -1,35 +0,0 @@
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

@ -1,43 +0,0 @@
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

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

View File

@ -1,9 +1,8 @@
import * as React from 'react' import DarkModeButton from '@/components/DarkModeButton';
import * as ReactDOM from 'react-dom' import ReactDOM from 'react-dom';
import DarkModeButton from '@/components/DarkModeButton'
const el = document.querySelector('#toggle-dark-mode') const element = document.querySelector('#toggle-dark-mode');
if (el) { if (element) {
const initMode = document.body.classList.contains('dark-mode') const initMode = document.body.classList.contains('dark-mode');
ReactDOM.render(<DarkModeButton initMode={initMode} />, el) ReactDOM.render(<DarkModeButton initMode={initMode}/>, element);
} }

View File

@ -1,9 +1,8 @@
import React from 'react' import EmailVerification from '@/views/widgets/EmailVerification';
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,20 +1,22 @@
/* eslint-disable @typescript-eslint/no-unnecessary-condition */ const bus = new Map<string | symbol, Set<(...args: any[]) => void>>();
const bus = new Map<string | symbol, Set<CallableFunction>>()
export function on(event: string | symbol, listener: CallableFunction) { export function on(event: string | symbol, listener: (...args: any[]) => void) {
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)
return () => { const listeners = bus.get(event)!;
listeners.delete(listener) listeners.add(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) => listener(payload)) bus.get(event)?.forEach(listener => {
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,25 +1,24 @@
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 { useState, useEffect } from 'react' import {useEffect, useState} 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 { useState, useEffect } from 'react' import {useEffect, useState} 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 | null { export default function useMount(selector: string): HTMLElement | undefined {
const container = useRef<HTMLDivElement | null>(null) const container = useRef<HTMLDivElement | undefined>(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.appendChild(div) mount.append(div);
return () => { return () => {
mount.removeChild(div) div.remove();
container.current = null container.current = null;
} };
}, [selector]) }, [selector]);
return container.current return container.current;
} }

View File

@ -1,26 +1,27 @@
import { useState, useEffect } from 'react' import {useEffect, useState} from 'react';
import * as fetch from '../net' import * as fetch from '../net';
import { Texture, TextureType } from '../types' import {type 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])
return [{ url, type }, setTid] as const getTexture();
}, [tid]);
return [{url, type}, setTid] as const;
} }

View File

@ -1,22 +1,22 @@
import { useState, useEffect, useRef } from 'react' import TWEEN from '@tweenjs/tween.js';
import TWEEN from '@tweenjs/tween.js' import {useEffect, useRef, useState} from 'react';
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 ref = useRef<T>(value) const reference = useRef<T>(value);
const [dest, setDest] = useState<T>(initialValue) const [destination, setDestination] = useState<T>(initialValue);
useEffect(() => { useEffect(() => {
function animate() { function animate() {
requestAnimationFrame(animate) requestAnimationFrame(animate);
TWEEN.update() TWEEN.update();
setValue(ref.current) setValue(reference.current);
} }
const tween = new TWEEN.Tween(ref) const tween = new TWEEN.Tween(reference);
tween.to({ current: dest }, 1000).start() tween.to({current: destination}, 1000).start();
animate() animate();
}, [dest]) }, [destination]);
return [value, setDest] as const return [value, setDestination] as const;
} }

View File

@ -1,31 +1,34 @@
interface I18nTable { type 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;
} }
export function t(key: string, parameters = Object.create(null)): string { Object.assign(window, {trans: t});
const segments = key.split('.') Object.assign(blessing, {t});
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 (process.env.NODE_ENV === 'development') { if (import.meta.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 { post } from './net' import {t} from './i18n';
import { t } from './i18n' import {post} from './net';
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,27 +1,26 @@
import React from 'react' import {createRoot} from 'react-dom/client';
import ReactDOM from 'react-dom' import Modal, {type ModalOptions, type ModalResult} from '../components/Modal';
import Modal, { ModalOptions, ModalResult } from '../components/Modal'
export function showModal(options: ModalOptions = {}): Promise<ModalResult> { export async 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.appendChild(container) document.body.append(container);
const root = createRoot(container);
const handleClose = () => { const handleClose = () => {
ReactDOM.unmountComponentAtNode(container) root.unmount();
document.body.removeChild(container) container.remove();
} };
ReactDOM.render( root.render((
<Modal <Modal
{...options} {...options}
show show
center center
onConfirm={resolve} onConfirm={resolve}
onDismiss={reject} onDismiss={reject}
onClose={handleClose} onClose={handleClose}
/>, />
container, ));
) });
})
} }

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