Compare commits

..

31 Commits
cleanup ... dev

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

* feat: limit max texture width to avoid png bomb

* style: apply php-cs-fixer fixes

* chore: set default value for max_texture_width option

* Update skinlib.yml

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

---------

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

https://t.me/blessing_skin/184899

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

fix: wrong link

* Fix Netlify link

---------

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

View File

@ -9,9 +9,10 @@ services:
# Update 'VARIANT' to pick a version of PHP version: 8, 8.1, 8.0, 7, 7.4
# Append -bullseye or -buster to pin to an OS version.
# Use -bullseye variants on local arm64/Apple Silicon.
VARIANT: 8-bullseye
VARIANT: "8-bullseye"
# Optional Node.js version
NODE_VERSION: 'lts/*'
NODE_VERSION: "lts/*"
volumes:
- ..:/workspace:cached

View File

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

9
.eslintignore Normal file
View File

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

27
.eslintrc.yml Normal file
View File

@ -0,0 +1,27 @@
root: true
parser: '@typescript-eslint/parser'
parserOptions:
project: tsconfig.eslint.json
plugins:
- '@typescript-eslint/eslint-plugin'
extends:
- eslint:recommended
- plugin:@typescript-eslint/recommended
- plugin:@typescript-eslint/recommended-requiring-type-checking
- plugin:react-hooks/recommended
rules:
prefer-const: error
'@typescript-eslint/no-unsafe-assignment': off
'@typescript-eslint/no-unsafe-member-access': off
'@typescript-eslint/no-unsafe-return': off
'@typescript-eslint/no-unused-vars': off
'@typescript-eslint/explicit-module-boundary-types': off
'@typescript-eslint/no-explicit-any': off
'@typescript-eslint/ban-ts-comment': off
'@typescript-eslint/no-non-null-assertion': off
'@typescript-eslint/no-floating-promises': off
'@typescript-eslint/no-misused-promises':
- off
- checksVoidReturn: false
'@typescript-eslint/unbound-method': off
'@typescript-eslint/restrict-template-expressions': off

View File

@ -26,14 +26,13 @@ jobs:
with:
php-version: 8.3
coverage: none
extensions: mbstring, dom, fileinfo, gd
extensions: mbstring, dom, fileinfo, gd, imagick
- name: Install dependencies
run: |
composer install --prefer-dist --no-progress
- name: Prepare
run: |
cp .env.example .env
php artisan key:generate
mkdir -p resources/views/overrides
- name: Validate Twig templates
run: php artisan twig:lint -v
@ -55,14 +54,14 @@ jobs:
with:
php-version: ${{ matrix.php }}
coverage: none
extensions: mbstring, dom, fileinfo, sqlite, gd, zip
extensions: mbstring, dom, fileinfo, sqlite, gd, zip, imagick
- name: Setup PHP with Xdebug
uses: shivammathur/setup-php@v2
if: matrix.php == '8.3'
with:
php-version: ${{ matrix.php }}
coverage: xdebug
extensions: mbstring, dom, fileinfo, sqlite, gd, zip
extensions: mbstring, dom, fileinfo, sqlite, gd, zip, imagick
- name: Cache Composer dependencies
uses: actions/cache@v3
with:
@ -120,7 +119,7 @@ jobs:
with:
php-version: 8.2
coverage: none
extensions: mbstring, dom, fileinfo, sqlite, gd, zip
extensions: mbstring, dom, fileinfo, sqlite, gd, zip, imagick
- name: Checkout code
uses: actions/checkout@v4
- name: Cache Node dependencies
@ -144,11 +143,13 @@ jobs:
yarn build
cp resources/assets/src/images/bg.webp public/app/
cp resources/assets/src/images/favicon.ico public/app/
- uses: benjlevesque/short-sha@v1.2
- uses: benjlevesque/short-sha@v3.0
id: short-sha
- 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
- name: Upload artifact
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
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

2
.gitignore vendored
View File

@ -25,3 +25,5 @@ storage/options.php
.phpunit.result.cache
.php-cs-fixer.cache
resources/views/overrides
.DS_Store
*/.DS_Store

View File

@ -13,15 +13,28 @@ tasks:
php artisan serve --host=0.0.0.0
- 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:
extensions:
- editorconfig.editorconfig
- eamodio.gitlens
- bmewburn.vscode-intelephense-client
- esbenp.prettier-vscode
- jpoissonnier.vscode-styled-components
- mblode.twig-language-2
- felixfbecker.php-debug
- 'editorconfig.editorconfig'
- 'eamodio.gitlens'
- 'bmewburn.vscode-intelephense-client'
- 'esbenp.prettier-vscode'
- 'jpoissonnier.vscode-styled-components'
- 'mblode.twig-language-2'
- 'felixfbecker.php-debug'
ports:
- port: 8080

4
.husky/pre-commit Executable file
View File

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

8
.vscode/launch.json vendored
View File

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

View File

@ -52,6 +52,7 @@ Blessing Skin 对您的服务器有一定的要求。在大多数情况下,下
- JSON
- fileinfo
- zip
- Imagick
## 快速使用
@ -61,105 +62,9 @@ Blessing Skin 对您的服务器有一定的要求。在大多数情况下,下
Blessing Skin 提供了强大的插件系统,您可以通过添加多种多样的插件来为您的皮肤站添加功能。
## 支持并赞助 Blessing Skin
如果您觉得这个软件对您很有帮助,欢迎通过赞助来支持开发!
目前可在 [爱发电](https://afdian.net/@blessing-skin) 上赞助。
### Sponsors
<table>
<tbody>
<tr>
<td align=center>
<a href="https://afdian.net/@gao_cai_sheng">
<img src="https://pic1.afdiancdn.com/user/2aac23481b1b11ea9f6e52540025c377/avatar/96a8b23d98cbac5aa36601db15a27e5e_w512_h512_s234.jpg" width="120" height="120">
<br>
gao_cai_sheng
</a>
</td>
<td align=center>
<a href="https://afdian.net/@LD_fantasy">
<img src="https://pic1.afdiancdn.com/user/9bed7bb454f011eb821652540025c377/avatar/cb679e3eac693e0eea2eac527c7954e0_w700_h1307_s137.jpg" width="120" height="120">
<br>
K_LazyCat
</a>
</td>
<td align=center>
<a href="https://afdian.net/@nmzy2018">
<img src="https://pic1.afdiancdn.com/user/a66f79d2f5a311e9af4e52540025c377/avatar/98682fb3c5914a39c8986bb1e97b5501_w512_h512_s248.jpg" width="120" height="120">
<br>
伊南
</a>
</td>
<td align=center>
<a href="">
<img src="https://pic1.afdiancdn.com/default/avatar/avatar-blue.png" width="120" height="120">
<br>
家乐
</a>
</td>
<td align=center>
<a href="https://afdian.net/@oar-01">
<img src="https://pic1.afdiancdn.com/user/e391f6ccdfa911ebb0e352540025c377/avatar/74da4afa92fa2666c306d43ab7a8804b_w1920_h1080_s338.jpg" width="120" height="120">
<br>
黄金鞘翅的郡主
</a>
</td>
</tr>
<tr>
<td align=center>
<a href="https://www.bilibili.plus/caucmc1.orz">
<img src="https://pic1.afdiancdn.com/user/edde2efc879611e889f552540025c377/avatar/d6a712efd6560b28989ac33f99c8915d_w473_h454_s24.jpg" width="120" height="120">
<br>
睡觉塞牙
</a>
</td>
</tr>
</tbody>
</table>
### Backers
<table>
<tbody>
<tr>
<td align=center>
<a href="https://afdian.net/@ValiantShishu976400">
<img src="https://pic1.afdiancdn.com/user/178a08963a5e11e9addd52540025c377/avatar/ece9f089aaf2c2f83204a8de11697caf_w350_h350_s16.jpg" width="75" height="75">
<br>
飒爽师叔
</a>
</td>
<td align=center>
<a href="https://afdian.net/@PAKingdom">
<img src="https://pic1.afdiancdn.com/user/18ad3338e58a11e9b29352540025c377/avatar/1e8b6476b589ddac545ac1ce13166e59_w584_h797_s59.jpg" width="75" height="75">
<br>
皮皮帕
</a>
</td>
<td align=center>
<a href="https://afdian.net/@oar-01">
<img src="https://pic1.afdiancdn.com/user/e391f6ccdfa911ebb0e352540025c377/avatar/74da4afa92fa2666c306d43ab7a8804b_w1920_h1080_s338.jpg" width="75" height="75">
<br>
黄金鞘翅的郡主
</a>
</td>
<td align=center>
<a href="">
<img src="https://pic1.afdiancdn.com/user/fc143860efa111ebb3e552540025c377/avatar/6e1d0f3f6ffb80b89b44269f59aa775f_w1080_h1080_s107.jpg" width="75" height="75">
<br>
♂sudo rm -rf /*[幼稚鬼]
</a>
</td>
</tr>
</tbody>
</table>
## 自行构建
详情可阅读 [这里](https://blessing.netlify.com/build.html)。
详情可阅读 [这里](https://blessing.netlify.app/build.html)。
> 您可以订阅我们的 Telegram 频道 [Blessing Skin News](https://t.me/blessing_skin_news) 来获取最新开发动态。当有新的 Commit 被推送时,我们的机器人将会在频道内发送一条消息来提示您能否拉取最新代码,以及拉取后应该做什么。
@ -171,7 +76,7 @@ Blessing Skin 可支持多种语言,当前支持英语、简体中文和西班
## 问题报告
请参阅 [报告问题的正确姿势](https://blessing.netlify.com/report.html)。
请参阅 [报告问题的正确姿势](https://blessing.netlify.app/report.html)。
## 相关链接

View File

@ -52,6 +52,7 @@ Blessing Skin has only a few system requirements. In most cases, these PHP exten
- JSON
- fileinfo
- zip
- Imagick
## Quick Install
@ -61,102 +62,6 @@ Please read [Installation Guide](https://blessing.netlify.app/en/setup.html).
Blessing Skin provides an elegant and powerful plugin system, and you can attach plenty of functions and customization to your site via installing plugins.
## 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
Please refer to [Manual Build](https://blessing.netlify.app/build.html).

View File

@ -86,7 +86,7 @@ class AdminController extends Controller
Request $request,
PluginManager $plugins,
Filesystem $filesystem,
Filter $filter
Filter $filter,
) {
$db = config('database.connections.'.config('database.default'));
$dbType = Arr::get([

View File

@ -8,16 +8,16 @@ use App\Mail\ForgotPassword;
use App\Models\Player;
use App\Models\User;
use App\Rules;
use Auth;
use Blessing\Filter;
use Blessing\Rejection;
use Cache;
use Carbon\Carbon;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Http\Request;
use Mail;
use Session;
use URL;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Session;
use Illuminate\Support\Facades\URL;
use Vectorface\Whip\Whip;
class AuthController extends Controller
@ -50,7 +50,7 @@ class AuthController extends Controller
Request $request,
Rules\Captcha $captcha,
Dispatcher $dispatcher,
Filter $filter
Filter $filter,
) {
$data = $request->validate([
'identification' => 'required',
@ -151,7 +151,7 @@ class AuthController extends Controller
Request $request,
Rules\Captcha $captcha,
Dispatcher $dispatcher,
Filter $filter
Filter $filter,
) {
$can = $filter->apply('can_register', null);
if ($can instanceof Rejection) {
@ -248,7 +248,7 @@ class AuthController extends Controller
Request $request,
Rules\Captcha $captcha,
Dispatcher $dispatcher,
Filter $filter
Filter $filter,
) {
$data = $request->validate([
'email' => 'required|email',

View File

@ -4,12 +4,12 @@ namespace App\Http\Controllers;
use App\Models\Texture;
use App\Models\User;
use Auth;
use Blessing\Filter;
use Blessing\Rejection;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class ClosetController extends Controller
{
@ -75,7 +75,7 @@ class ClosetController extends Controller
public function add(
Request $request,
Dispatcher $dispatcher,
Filter $filter
Filter $filter,
) {
['tid' => $tid, 'name' => $name] = $request->validate([
'tid' => 'required|integer',
@ -132,7 +132,7 @@ class ClosetController extends Controller
Request $request,
Dispatcher $dispatcher,
Filter $filter,
$tid
$tid,
) {
['name' => $name] = $request->validate(['name' => 'required']);
/** @var User */

View File

@ -163,6 +163,10 @@ class OptionsController extends Controller
->text('max_upload_file_size')->addon('KB')
->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')
->option('official', trans('options.general.player_name_rule.official'))
->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\User;
use App\Rules;
use Auth;
use Blessing\Filter;
use Blessing\Rejection;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rule;
class PlayerController extends Controller
@ -124,7 +124,7 @@ class PlayerController extends Controller
public function delete(
Dispatcher $dispatcher,
Filter $filter,
Player $player
Player $player,
) {
/** @var User */
$user = auth()->user();
@ -157,7 +157,7 @@ class PlayerController extends Controller
Request $request,
Dispatcher $dispatcher,
Filter $filter,
Player $player
Player $player,
) {
$name = $request->validate([
'name' => [
@ -194,7 +194,7 @@ class PlayerController extends Controller
Request $request,
Dispatcher $dispatcher,
Filter $filter,
Player $player
Player $player,
) {
/** @var User */
$user = auth()->user();
@ -234,7 +234,7 @@ class PlayerController extends Controller
Request $request,
Dispatcher $dispatcher,
Filter $filter,
Player $player
Player $player,
) {
$types = $request->input('type', []);

View File

@ -44,7 +44,7 @@ class PlayersManagementController extends Controller
public function name(
Player $player,
Request $request,
Dispatcher $dispatcher
Dispatcher $dispatcher,
) {
$name = $request->validate([
'player_name' => [
@ -70,7 +70,7 @@ class PlayersManagementController extends Controller
public function owner(
Player $player,
Request $request,
Dispatcher $dispatcher
Dispatcher $dispatcher,
) {
$uid = $request->validate(['uid' => 'required|integer'])['uid'];
@ -96,7 +96,7 @@ class PlayersManagementController extends Controller
public function texture(
Player $player,
Request $request,
Dispatcher $dispatcher
Dispatcher $dispatcher,
) {
$data = $request->validate([
'tid' => 'required|integer',
@ -123,7 +123,7 @@ class PlayersManagementController extends Controller
public function delete(
Player $player,
Dispatcher $dispatcher
Dispatcher $dispatcher,
) {
$dispatcher->dispatch('player.deleting', [$player]);

View File

@ -77,7 +77,7 @@ class ReportController extends Controller
public function review(
Report $report,
Request $request,
Dispatcher $dispatcher
Dispatcher $dispatcher,
) {
$data = $request->validate([
'action' => ['required', Rule::in(['delete', 'ban', 'reject'])],

View File

@ -20,7 +20,7 @@ class SetupController extends Controller
Request $request,
Filesystem $filesystem,
Connection $connection,
DatabaseManager $manager
DatabaseManager $manager,
) {
if ($request->isMethod('get')) {
try {
@ -121,7 +121,7 @@ class SetupController extends Controller
'database/migrations',
'vendor/laravel/passport/database/migrations',
],
]);
]);
$siteUrl = url('/');
if (Str::endsWith($siteUrl, '/index.php')) {

View File

@ -4,7 +4,6 @@ namespace App\Http\Controllers;
use App\Models\Texture;
use App\Models\User;
use Auth;
use Blessing\Filter;
use Blessing\Rejection;
use Illuminate\Contracts\Events\Dispatcher;
@ -12,10 +11,12 @@ use Illuminate\Database\Eloquent\Builder;
use Illuminate\Filesystem\FilesystemAdapter;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\Rule;
use Intervention\Image\Facades\Image;
use League\CommonMark\GithubFlavoredMarkdownConverter;
use Storage;
class SkinlibController extends Controller
{
@ -189,7 +190,7 @@ class SkinlibController extends Controller
public function handleUpload(
Request $request,
Filter $filter,
Dispatcher $dispatcher
Dispatcher $dispatcher,
) {
$file = $request->file('file');
if ($file && !$file->isValid()) {
@ -220,6 +221,16 @@ class SkinlibController extends Controller
$type = $data['type'];
$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) {
$message = trans('skinlib.upload.invalid-size', [
'type' => $type === 'cape' ? trans('general.cape') : trans('general.skin'),
@ -253,8 +264,17 @@ class SkinlibController extends Controller
}
}
$hash = hash_file('sha256', $file);
$hash = $filter->apply('uploaded_texture_hash', $hash, [$file]);
$image = Image::make($file);
$imagick = $image->getCore();
$imagick->setOption('png:compression-filter', '0');
$imagick->setOption('png:compression-level', '9');
$imagick->setOption('png:compression-strategy', '0');
$imagick->setOption('png:exclude-chunk', 'all');
$imagick->stripImage();
$sanitized = $image->encode('png')->getEncoded();
$hash = hash('sha256', $image->encoded);
$hash = $filter->apply('uploaded_texture_hash', $hash, [$image]);
/** @var User */
$user = Auth::user();
@ -270,11 +290,11 @@ class SkinlibController extends Controller
return json(trans('skinlib.upload.repeated'), 2, ['tid' => $duplicated->tid]);
}
$size = ceil($file->getSize() / 1024);
$fileSize = ceil(strlen($sanitized) / 1024);
$isPublic = is_string($data['public'])
? $data['public'] === '1'
: $data['public'];
$cost = $size * (
$cost = $fileSize * (
$isPublic
? option('score_per_storage')
: option('private_score_per_storage')
@ -285,13 +305,13 @@ class SkinlibController extends Controller
return json(trans('skinlib.upload.lack-score'), 1);
}
$dispatcher->dispatch('texture.uploading', [$file, $name, $hash]);
$dispatcher->dispatch('texture.uploading', [$image, $name, $hash]);
$texture = new Texture();
$texture->name = $name;
$texture->type = $type;
$texture->hash = $hash;
$texture->size = $size;
$texture->size = $fileSize;
$texture->public = $isPublic;
$texture->uploader = $user->uid;
$texture->likes = 1;
@ -300,14 +320,14 @@ class SkinlibController extends Controller
/** @var FilesystemAdapter */
$disk = Storage::disk('textures');
if ($disk->missing($hash)) {
$file->storePubliclyAs('', $hash, ['disk' => 'textures']);
$disk->put($hash, $sanitized);
}
$user->score -= $cost;
$user->closet()->attach($texture->tid, ['item_name' => $name]);
$user->save();
$dispatcher->dispatch('texture.uploaded', [$texture, $file]);
$dispatcher->dispatch('texture.uploaded', [$texture, $image]);
return json(trans('skinlib.upload.success', ['name' => $name]), 0, [
'tid' => $texture->tid,
@ -386,7 +406,7 @@ class SkinlibController extends Controller
Request $request,
Dispatcher $dispatcher,
Filter $filter,
Texture $texture
Texture $texture,
) {
$data = $request->validate(['name' => [
'required',
@ -416,7 +436,7 @@ class SkinlibController extends Controller
Request $request,
Dispatcher $dispatcher,
Filter $filter,
Texture $texture
Texture $texture,
) {
$data = $request->validate([
'type' => ['required', Rule::in(['steve', 'alex', 'cape'])],

View File

@ -6,11 +6,11 @@ use App\Models\Player;
use App\Models\Texture;
use App\Models\User;
use Blessing\Minecraft;
use Cache;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Image;
use Storage;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage;
use Intervention\Image\Facades\Image;
class TextureController extends Controller
{
@ -71,7 +71,8 @@ class TextureController extends Controller
$lastModified = $disk->lastModified($hash);
return Image::make($image)
// TODO: refactor
return \Intervention\Image\ImageManagerStatic::configure(['driver' => 'gd'])->make($image)
->response($usePNG ? 'png' : 'webp', 100)
->setLastModified(Carbon::createFromTimestamp($lastModified));
}
@ -145,7 +146,8 @@ class TextureController extends Controller
$disk = Storage::disk('textures');
if (is_null($texture) || $disk->missing($texture->hash)) {
return Image::make(resource_path("misc/textures/avatar$mode.png"))
// TODO: refactor
return \Intervention\Image\ImageManagerStatic::configure(['driver' => 'gd'])->make(resource_path("misc/textures/avatar$mode.png"))
->resize($size, $size)
->response($usePNG ? 'png' : 'webp', 100);
}
@ -165,7 +167,8 @@ class TextureController extends Controller
$lastModified = Carbon::createFromTimestamp($disk->lastModified($hash));
return Image::make($image)
// TODO: refactor
return \Intervention\Image\ImageManagerStatic::configure(['driver' => 'gd'])->make($image)
->resize($size, $size)
->response($usePNG ? 'png' : 'webp', 100)
->setLastModified($lastModified);

View File

@ -40,7 +40,7 @@ class TranslationsController extends Controller
Request $request,
Application $app,
JavaScript $js,
LanguageLine $line
LanguageLine $line,
) {
$data = $request->validate(['text' => 'required|string']);
@ -57,7 +57,7 @@ class TranslationsController extends Controller
public function delete(
Application $app,
JavaScript $js,
LanguageLine $line
LanguageLine $line,
) {
$line->delete();

View File

@ -6,16 +6,16 @@ use App\Events\UserProfileUpdated;
use App\Mail\EmailVerification;
use App\Models\Texture;
use App\Models\User;
use Auth;
use Blessing\Filter;
use Blessing\Rejection;
use Carbon\Carbon;
use Illuminate\Contracts\Events\Dispatcher;
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 Mail;
use Session;
use URL;
class UserController extends Controller
{

View File

@ -9,10 +9,10 @@ use Illuminate\Http\Request;
class CheckRole
{
protected $roles = [
'banned' => USER::BANNED,
'normal' => USER::NORMAL,
'admin' => USER::ADMIN,
'super-admin' => USER::SUPER_ADMIN,
'banned' => User::BANNED,
'normal' => User::NORMAL,
'admin' => User::ADMIN,
'super-admin' => User::SUPER_ADMIN,
];
public function handle(Request $request, Closure $next, $role)

View File

@ -28,7 +28,9 @@ class DetectLanguagePrefer
/** @var Response */
$response = $next($request);
$response->cookie('locale', $locale, 120);
if (!in_array('api', optional($request->route())->middleware() ?? [])) {
$response->cookie('locale', $locale, 120);
}
return $response;
}

View File

@ -22,7 +22,7 @@ class FootComposer
Request $request,
JavaScript $javascript,
Dispatcher $dispatcher,
Filter $filter
Filter $filter,
) {
$this->request = $request;
$this->javascript = $javascript;

View File

@ -20,7 +20,7 @@ class HeadComposer
public function __construct(
Dispatcher $dispatcher,
Request $request,
Filter $filter
Filter $filter,
) {
$this->dispatcher = $dispatcher;
$this->request = $request;

View File

@ -41,6 +41,7 @@ class UserMenuComposer
['label' => trans('general.admin-panel'), 'link' => route('admin.view')],
['label' => trans('general.user-manage'), 'link' => route('admin.users.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]);

View File

@ -3,7 +3,7 @@
namespace App\Listeners;
use App\Models\User;
use Event;
use Illuminate\Support\Facades\Event;
class NotifyFailedPlugin
{

View File

@ -4,6 +4,7 @@ namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Headers;
use Illuminate\Queue\SerializesModels;
class EmailVerification extends Mailable
@ -26,4 +27,13 @@ class EmailVerification extends Mailable
->subject(trans('user.verification.mail.title', ['sitename' => $site_name]))
->view('mails.email-verification');
}
public function headers(): Headers
{
return new Headers(
text: [
'Auto-Submitted' => 'auto-generated',
]
);
}
}

View File

@ -4,6 +4,7 @@ namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Headers;
use Illuminate\Queue\SerializesModels;
class ForgotPassword extends Mailable
@ -26,4 +27,13 @@ class ForgotPassword extends Mailable
->subject(trans('auth.forgot.mail.title', ['sitename' => $site_name]))
->view('mails.password-reset');
}
public function headers(): Headers
{
return new Headers(
text: [
'Auto-Submitted' => 'auto-generated',
]
);
}
}

View File

@ -3,7 +3,6 @@
namespace App\Models;
use App\Events\PlayerProfileUpdated;
use App\Models;
use DateTimeInterface;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@ -57,17 +56,17 @@ class Player extends Model
public function user()
{
return $this->belongsTo(Models\User::class, 'uid');
return $this->belongsTo(User::class, 'uid');
}
public function skin()
{
return $this->belongsTo(Models\Texture::class, 'tid_skin');
return $this->belongsTo(Texture::class, 'tid_skin');
}
public function cape()
{
return $this->belongsTo(Models\Texture::class, 'tid_cape');
return $this->belongsTo(Texture::class, 'tid_cape');
}
public function getModelAttribute()

View File

@ -2,8 +2,10 @@
namespace App\Providers;
use App\Models\Scope;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage;
use Laravel\Passport\Passport;
class AuthServiceProvider extends ServiceProvider
@ -39,7 +41,19 @@ class AuthServiceProvider extends ServiceProvider
'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));

View File

@ -8,10 +8,10 @@ use App\Events;
use App\Notifications;
use Blessing\Filter;
use Closure;
use Event;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Str;
use Notification;
class Hook
{

View File

@ -2,10 +2,10 @@
namespace App\Services;
use DB;
use Illuminate\Database\QueryException;
use Illuminate\Filesystem\Filesystem;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
class Option
{
@ -20,13 +20,14 @@ class Option
return;
}
try {
$this->items = DB::table('options')
->get()
->mapWithKeys(fn ($item) => [$item->option_name => $item->option_value]);
} catch (QueryException $e) {
if (!file_exists(storage_path('install.lock')) || app()->runningUnitTests()) {
$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)

View File

@ -2,10 +2,10 @@
namespace App\Services;
use App\Services\Facades\Option;
use BadMethodCallException;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Option;
use ReflectionClass;
/**
@ -203,7 +203,7 @@ class OptionForm
/**
* 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();
$allPostData = $request->all();

View File

@ -36,7 +36,7 @@ class PluginManager
Application $app,
Option $option,
Dispatcher $dispatcher,
Filesystem $filesystem
Filesystem $filesystem,
) {
$this->app = $app;
$this->option = $option;
@ -366,7 +366,7 @@ class PluginManager
*/
public function formatUnresolved(
Collection $unsatisfied,
Collection $conflicts
Collection $conflicts,
): array {
$unsatisfied = $unsatisfied->map(function ($detail, $name) {
if ($name === 'blessing-skin-server') {

View File

@ -20,7 +20,7 @@ class JavaScript
public function __construct(
Filesystem $filesystem,
Repository $cache,
PluginManager $plugins
PluginManager $plugins,
) {
$this->filesystem = $filesystem;
$this->cache = $cache;

View File

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

View File

@ -6,6 +6,7 @@
"php": "^8.1",
"ext-ctype": "*",
"ext-gd": "*",
"ext-imagick": "*",
"ext-json": "*",
"ext-mbstring": "*",
"ext-openssl": "*",
@ -29,7 +30,7 @@
"lorisleiva/laravel-search-string": "^1.0",
"nesbot/carbon": "^2.0",
"nunomaduro/collision": "^7.0",
"rcrowe/twigbridge": "dev-blessing",
"rcrowe/twigbridge": "^0.14",
"spatie/laravel-translation-loader": "^2.7",
"symfony/process": "^6.0",
"symfony/yaml": "^5.0",
@ -81,14 +82,10 @@
]
}
},
"repositories": [
{
"type": "vcs",
"url": "https://github.com/bs-community/TwigBridge"
},
{
"repositories": {
"packagist": {
"type": "composer",
"url": "https://packagist.org/"
}
]
}
}

1829
composer.lock generated

File diff suppressed because it is too large Load Diff

20
config/image.php Normal file
View File

@ -0,0 +1,20 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Image Driver
|--------------------------------------------------------------------------
|
| Intervention Image supports "GD Library" and "Imagick" to process images
| internally. You may choose one of them according to your PHP
| configuration. By default PHP's "GD Library" implementation is used.
|
| Supported: "gd", "imagick"
|
*/
'driver' => 'imagick'
];

View File

@ -33,7 +33,7 @@ return [
*/
'registry' => env(
'PLUGINS_REGISTRY',
'https://d2jw1l0ullrzt6.cloudfront.net/registry_{lang}.json'
'https://bs-plugins.littleservice.cn/registry_{lang}.json'
),
/*

View File

@ -123,7 +123,6 @@ return [
// 'TwigBridge\Extension\Laravel\Form',
// 'TwigBridge\Extension\Laravel\Html',
'TwigBridge\Extension\Laravel\Vite',
// 'TwigBridge\Extension\Laravel\Legacy\Facades',
],
@ -154,8 +153,7 @@ return [
| in order to be marked as safe.
|
*/
'facades' => [
],
'facades' => [],
/*
|--------------------------------------------------------------------------

View File

@ -2,6 +2,7 @@
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateAllTables extends Migration
{

View File

@ -1,6 +1,8 @@
<?php
use App\Services\Facades\Option;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
class ImportOptions extends Migration
{

View File

@ -2,6 +2,7 @@
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class AddVerificationToUsersTable extends Migration
{

View File

@ -0,0 +1,21 @@
<?php
use App\Services\Facades\Option;
use Illuminate\Database\Migrations\Migration;
return new class extends Migration {
/**
* Run the migrations.
*/
public function up(): void
{
Option::set('max_texture_width', 8192);
}
/**
* Reverse the migrations.
*/
public function down(): void
{
}
};

View File

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

View File

@ -1,90 +1,163 @@
{
"name": "blessing-skin-server",
"type": "module",
"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.",
"author": "printempw",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/bs-community/blessing-skin-server"
},
"author": "printempw",
"license": "MIT",
"private": true,
"scripts": {
"build": "vite build",
"dev": "webpack serve",
"build": "webpack --env production --progress",
"lint": "eslint --ext=ts -f=beauty .",
"fmt": "prettier --write resources/assets tools webpack.config.ts",
"fmt:check": "prettier --check resources/assets tools webpack.config.ts",
"type:check": "tsc -p . --noEmit && tsc -p ./resources/assets/tests --noEmit",
"test": "jest",
"build:urls": "ts-node tools/generateUrls.ts",
"dev": "vite",
"lint": "eslint .",
"test": "vitest"
"prepare": "husky install"
},
"browserslist": [
"Firefox ESR",
"iOS >= 12.5",
"Chrome >= 87"
],
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@fortawesome/fontawesome-free": "^6.7.2",
"@tweenjs/tween.js": "^25.0.0",
"admin-lte": "4.0.0-beta3",
"bootstrap": "^5.3.3",
"clsx": "^2.1.1",
"downshift": "^9.0.8",
"echarts": "^5.6.0",
"immer": "^10.1.1",
"@emotion/react": "^11.0.0",
"@emotion/styled": "^11.0.0",
"@fortawesome/fontawesome-free": "^6.3.0",
"@hot-loader/react-dom": "^17.0.0",
"@tweenjs/tween.js": "^18.5.0",
"admin-lte": "^3.2.0",
"blessing-skin-shell": "^0.3.4",
"bootstrap": "^4.6.1",
"cac": "6.6.1",
"cli-spinners": "^2.5.0",
"clsx": "^1.1.1",
"echarts": "^5.1.2",
"events": "^3.2.0",
"immer": "^7.0.4",
"jquery": "^3.6.0",
"lodash-es": "^4.0.8",
"nanoid": "^5.0.9",
"lodash.debounce": "^4.0.8",
"nanoid": "^3.1.9",
"prompts": "^2.4.0",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"react": "^17.0.1",
"react-autosuggest": "^10.0.2",
"react-dom": "^17.0.1",
"react-draggable": "^4.4.2",
"react-loading-skeleton": "^3.5.0",
"react-use": "^17.6.0",
"react-hot-loader": "^4.12.21",
"react-loading-skeleton": "^2.1.1",
"react-use": "^17.4.0",
"reaptcha": "^1.7.2",
"rxjs": "^7.8.1",
"skinview-utils": "^0.7.1",
"skinview3d": "^3.1.0",
"spectre.css": "github:angular-package/spectre.css",
"use-immer": "^0.11.0"
"rxjs": "^6.5.5",
"skinview-utils": "^0.5.5",
"skinview3d": "^3.0.0-alpha.1",
"spectre.css": "^0.5.8",
"use-immer": "^0.4.2",
"xterm": "^4.6.0",
"xterm-addon-fit": "^0.4.0"
},
"devDependencies": {
"@eslint-react/eslint-plugin": "^1.23.2",
"@mochaa/eslintrc": "^0.1.12",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@tsconfig/vite-react": "^3.4.0",
"@types/bootstrap": "^5.2.10",
"@types/jquery": "^3.5.32",
"@types/js-yaml": "^4.0.9",
"@types/lodash-es": "^4.0.6",
"@gplane/tsconfig": "^4.2.0",
"@testing-library/jest-dom": "^5.11.10",
"@testing-library/react": "^11.2.6",
"@types/bootstrap": "^4.3.3",
"@types/css-minimizer-webpack-plugin": "^1.1.0",
"@types/jest": "^26.0.23",
"@types/jquery": "^3.5.13",
"@types/js-yaml": "^3.12.4",
"@types/lodash.debounce": "^4.0.6",
"@types/mini-css-extract-plugin": "^1.2.1",
"@types/prompts": "^2.0.9",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/react": "^16.9.35",
"@types/react-autosuggest": "^9.3.14",
"@types/react-dom": "^16.9.8",
"@types/tween.js": "^18.5.0",
"@vitejs/plugin-react-swc": "^3.7.2",
"autoprefixer": "^10.4.20",
"browserslist": "^4.24.4",
"browserslist-to-esbuild": "^2.1.1",
"eslint": "^9.18.0",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.18",
"js-yaml": "^4.1.0",
"laravel-vite-plugin": "^1.1.1",
"postcss": "^8.5.1",
"sass": "^1.83.4",
"typescript": "^5.7.3",
"vite": "^6.0.7",
"vitest": "^3.0.2"
"@types/webpack-dev-server": "^3.11.0",
"@typescript-eslint/eslint-plugin": "^3.6.0",
"@typescript-eslint/parser": "^3.6.0",
"autoprefixer": "^10.2.6",
"css-loader": "^5.2.6",
"css-minimizer-webpack-plugin": "^3.0.1",
"eslint": "^7.4.0",
"eslint-formatter-beauty": "^3.0.0",
"eslint-plugin-react-hooks": "^4.3.0",
"html-webpack-plugin": "^5.3.1",
"husky": "^7.0.4",
"jest": "^27.0.4",
"jest-extended": "^0.11.5",
"js-yaml": "^3.13.1",
"mini-css-extract-plugin": "^1.6.0",
"postcss": "^8.3.0",
"postcss-loader": "^5.3.0",
"prettier": "^2.3.0",
"pretty-quick": "^3.1.3",
"style-loader": "^2.0.0",
"ts-jest": "^27.0.2",
"ts-loader": "^9.2.2",
"ts-node": "^10.0.0",
"typescript": "^4.3.2",
"webpack": "^5.38.1",
"webpack-cli": "^4.7.0",
"webpack-dev-server": "^3.11.2"
},
"resolutions": {
"kleur": "^4.1.3"
},
"postcss": {
"plugins": {
"autoprefixer": {}
"browserslist": [
"> 1%",
"not dead",
"not ie 11",
"Chrome > 52"
],
"prettier": {
"printWidth": 80,
"semi": false,
"singleQuote": true,
"trailingComma": "all",
"tabWidth": 2
},
"jest": {
"preset": "ts-jest",
"resetMocks": true,
"testEnvironment": "jsdom",
"moduleFileExtensions": [
"js",
"ts",
"tsx",
"json",
"node"
],
"moduleNameMapper": {
"\\.css$": "<rootDir>/resources/assets/tests/__mocks__/style.ts",
"\\.(png|webp)$": "<rootDir>/resources/assets/tests/__mocks__/file.ts",
"^@/(.*)$": "<rootDir>/resources/assets/src/$1"
},
"setupFilesAfterEnv": [
"<rootDir>/resources/assets/tests/setup.ts"
],
"coveragePathIgnorePatterns": [
"/node_modules/",
"<rootDir>/resources/assets/src/styles",
"<rootDir>/resources/assets/src/scripts/extra.ts",
"<rootDir>/resources/assets/src/scripts/urls.ts",
"<rootDir>/resources/assets/tests/setup",
"<rootDir>/resources/assets/tests/utils",
"<rootDir>/resources/assets/tests/scripts/cli/stdio"
],
"testMatch": [
"<rootDir>/resources/assets/tests/**/*.test.ts",
"<rootDir>/resources/assets/tests/**/*.test.tsx"
],
"testPathIgnorePatterns": [
"/node_modules/",
"<rootDir>/resources/assets/tests/(views|components)/.*\\.ts$"
],
"maxWorkers": "50%",
"globals": {
"ts-jest": {
"tsconfig": "<rootDir>/resources/assets/tests/tsconfig.json",
"isolatedModules": true
}
}
}
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}

5
postcss.config.js Normal file
View File

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

2
public/.gitignore vendored
View File

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

View File

@ -1,6 +0,0 @@
@import '@/styles/common.css';
@import 'admin-lte/src/scss/adminlte.scss';
@import '@fortawesome/fontawesome-free/scss/fontawesome.scss';
@import '@fortawesome/fontawesome-free/scss/regular.scss';
@import '@fortawesome/fontawesome-free/scss/solid.scss';
@import '@fortawesome/fontawesome-free/scss/brands.scss';

View File

@ -1,28 +1,28 @@
type AlertType = 'success' | 'info' | 'warning' | 'danger';
import React from 'react'
type AlertType = 'success' | 'info' | 'warning' | 'danger'
const icons = new Map<AlertType, string>([
['success', 'check'],
['info', 'info'],
['warning', 'exclamation-triangle'],
['danger', 'times-circle'],
]);
['success', 'check'],
['info', 'info'],
['warning', 'exclamation-triangle'],
['danger', 'times-circle'],
])
type Props = {
readonly type: AlertType;
readonly children?: React.ReactNode;
};
interface Props {
type: AlertType
}
const Alert: React.FC<Props> = ({type, children}) => {
const icon = icons.get(type);
const Alert: React.FC<Props> = (props) => {
const { type } = props
const icon = icons.get(type)
return children === ''
? null
: (
<div className={`alert alert-${type}`}>
<i className={`icon fas fa-${icon}`}/>
{children}
</div>
);
};
return props.children ? (
<div className={`alert alert-${type}`}>
<i className={`icon fas fa-${icon}`}></i>
{props.children}
</div>
) : null
}
export default Alert;
export default Alert

View File

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

View File

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

View File

@ -1,27 +1,27 @@
import * as fetch from '@/scripts/net';
import {useState} from 'react';
import React, { useState } from 'react'
import * as fetch from '@/scripts/net'
type Props = {
readonly initMode: boolean;
};
interface Props {
initMode: boolean
}
const DarkModeButton: React.FC<Props> = ({initMode}) => {
const [darkMode, setDarkMode] = useState(initMode);
const DarkModeButton: React.FC<Props> = ({ initMode }) => {
const [darkMode, setDarkMode] = useState(initMode)
const icon = darkMode ? 'moon' : 'sun';
const icon = darkMode ? 'moon' : 'sun'
const handleClick = async () => {
setDarkMode(value => !value);
const handleClick = async () => {
setDarkMode((value) => !value)
await fetch.put('/user/dark-mode');
document.body.classList.toggle('dark-mode');
};
await fetch.put('/user/dark-mode')
document.body.classList.toggle('dark-mode')
}
return (
<a className='nav-link' href='#' role='button' onClick={handleClick}>
<i className={`fas fa-${icon}`}/>
</a>
);
};
return (
<a className="nav-link" href="#" role="button" onClick={handleClick}>
<i className={`fas fa-${icon}`}></i>
</a>
)
}
export default DarkModeButton;
export default DarkModeButton

View File

@ -1,67 +1,89 @@
import {emit} from '@/scripts/event';
import {pointerCursor} from '@/styles/utils';
import {css} from '@emotion/react';
import clsx from 'clsx';
import {useCombobox} from 'downshift';
import {useEffect, useState} from 'react';
/** @jsxImportSource @emotion/react */
import React, { useState, useEffect } from 'react'
import Autosuggest from 'react-autosuggest'
import { css } from '@emotion/react'
import { emit } from '@/scripts/event'
import { pointerCursor } from '@/styles/utils'
const styles = css`
.dropdown-menu li {
${pointerCursor}
}
`;
`
const domainNames = new Set(['qq.com', '163.com', 'gmail.com', 'hotmail.com']);
const domainNames = new Set(['qq.com', '163.com', 'gmail.com', 'hotmail.com'])
type Props = Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange'> & {
onChange: (value: string) => void;
};
type Props = Omit<Autosuggest.InputProps<string>, 'onChange'> & {
onChange(value: string): void
}
const EmailSuggestion: React.FC<Props> = props => {
useEffect(() => {
emit('emailDomainsSuggestion', domainNames);
}, []);
const [inputItems, setInputItems] = useState<string[]>([]);
const EmailSuggestion: React.FC<Props> = (props) => {
const [suggestions, setSuggestions] = useState<string[]>([])
const {
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([]);
}
useEffect(() => {
emit('emailDomainsSuggestion', domainNames)
}, [])
const {onChange} = props;
onChange(value);
},
});
const handleSuggestionsFetchRequested: Autosuggest.SuggestionsFetchRequested =
({ value }) => {
const segments = value.split('@')
setSuggestions([...domainNames].map((name) => `${segments[0]}@${name}`))
}
return (
<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 handleSuggestionsClearRequested = () => {
setSuggestions([])
}
export default EmailSuggestion;
const shouldRenderSuggestions = (value: string) => {
const isSelecting = [...domainNames].some((name) =>
value.endsWith(`@${name}`),
)
return isSelecting || (value.length > 0 && !value.includes('@'))
}
const getSuggestionValue = (value: string) => value
const renderSuggestion = (suggestion: string) => suggestion
const handleChange = (_: React.FormEvent, event: Autosuggest.ChangeEvent) => {
props.onChange(event.newValue)
}
const renderInputComponent = (
props: Omit<Autosuggest.InputProps<string>, 'onChange'>,
) => (
<div className="input-group">
<input className="form-control" {...props} />
<div className="input-group-append">
<div className="input-group-text">
<i className="fas fa-envelope"></i>
</div>
</div>
</div>
)
return (
<div css={styles}>
<Autosuggest
suggestions={suggestions}
onSuggestionsFetchRequested={handleSuggestionsFetchRequested}
onSuggestionsClearRequested={handleSuggestionsClearRequested}
getSuggestionValue={getSuggestionValue}
renderSuggestion={renderSuggestion}
shouldRenderSuggestions={shouldRenderSuggestions}
inputProps={Object.assign({}, props, { onChange: handleChange })}
renderInputComponent={renderInputComponent}
theme={{
container: 'mb-3',
suggestion: 'dropdown-item',
suggestionsContainer: 'dropdown',
suggestionsList: `dropdown-menu ${suggestions.length ? 'show' : ''}`,
suggestionHighlighted: 'active',
}}
/>
</div>
)
}
export default EmailSuggestion

View File

@ -1,52 +1,53 @@
import {t} from '@/scripts/i18n';
import {css} from '@emotion/react';
import {useRef} from 'react';
/** @jsxImportSource @emotion/react */
import { useRef } from 'react'
import { css } from '@emotion/react'
import { t } from '@/scripts/i18n'
const hideRawBrowseButton = css`
::after {
display: none;
}
`;
`
type Props = {
file: File | undefined;
accept?: string;
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
};
interface Props {
file: File | null
accept?: string
onChange(event: React.ChangeEvent<HTMLInputElement>): void
}
const FileInput: React.FC<Props> = props => {
const reference = useRef<HTMLInputElement>(null);
const FileInput: React.FC<Props> = (props) => {
const ref = useRef<HTMLInputElement>(null)
const handleClick = () => {
reference.current!.click();
};
const handleClick = () => {
ref.current!.click()
}
return (
<div className='form-group'>
<label htmlFor='select-file'>{t('skinlib.upload.select-file')}</label>
<div className='input-group'>
<div className='custom-file'>
<input
ref={reference}
type='file'
className='custom-file-input'
id='select-file'
accept={props.accept}
title={t('skinlib.upload.select-file')}
onChange={props.onChange}
/>
<label className='custom-file-label' css={hideRawBrowseButton}>
{props.file?.name}
</label>
</div>
<div className='input-group-append'>
<button className='btn btn-default' onClick={handleClick}>
{t('skinlib.upload.select-file')}
</button>
</div>
</div>
</div>
);
};
return (
<div className="form-group">
<label htmlFor="select-file">{t('skinlib.upload.select-file')}</label>
<div className="input-group">
<div className="custom-file">
<input
type="file"
className="custom-file-input"
id="select-file"
accept={props.accept}
title={t('skinlib.upload.select-file')}
ref={ref}
onChange={props.onChange}
/>
<label className="custom-file-label" css={hideRawBrowseButton}>
{props.file?.name}
</label>
</div>
<div className="input-group-append">
<button className="btn btn-default" onClick={handleClick}>
{t('skinlib.upload.select-file')}
</button>
</div>
</div>
</div>
)
}
export default FileInput;
export default FileInput

View File

@ -1,9 +1,9 @@
function Loading() {
return (
<div className='container text-center' title='Loading...'>
<i className='fas fa-sync fa-spin'/>
</div>
);
}
import React from 'react'
export default Loading;
const Loading = () => (
<div className="container text-center" title="Loading...">
<i className="fas fa-sync fa-spin"></i>
</div>
)
export default Loading

View File

@ -1,193 +1,165 @@
import {Modal as BootstrapModal} from 'bootstrap';
import clsx from 'clsx';
import {useEffect, useRef, useState} from 'react';
import {t} from '../scripts/i18n';
import ModalBody, {type Props as BodyProps} from './ModalBody';
import ModalFooter, {type Props as FooterProps} from './ModalFooter';
import ModalHeader, {type Props as HeaderProps} from './ModalHeader';
import React, { useState, useEffect, useRef } from 'react'
import $ from 'jquery'
import 'bootstrap'
import { t } from '../scripts/i18n'
import ModalHeader from './ModalHeader'
import ModalBody from './ModalBody'
import ModalFooter from './ModalFooter'
import type { Props as HeaderProps } from './ModalHeader'
import type { Props as BodyProps } from './ModalBody'
import type { Props as FooterProps } from './ModalFooter'
type BasicOptions = {
readonly mode?: 'alert' | 'confirm' | 'prompt';
readonly show?: boolean;
readonly input?: string;
validator?: (value: any) => string | boolean | undefined;
readonly type?: string;
readonly showHeader?: boolean;
readonly center?: boolean;
children?: React.ReactNode;
};
mode?: 'alert' | 'confirm' | 'prompt'
show?: boolean
input?: string
validator?(value: any): string | boolean | undefined
type?: string
showHeader?: boolean
center?: boolean
children?: React.ReactNode
}
export type ModalOptions = BasicOptions & HeaderProps & BodyProps & FooterProps;
export type ModalOptions = BasicOptions & HeaderProps & BodyProps & FooterProps
type Props = {
readonly id?: string;
readonly children?: React.ReactNode;
readonly footer?: React.ReactNode;
onConfirm?: (payload: {value: string}) => void;
onDismiss?: () => void;
onClose?: () => void;
};
id?: string
children?: React.ReactNode
footer?: React.ReactNode
onConfirm?(payload: { value: string }): void
onDismiss?(): void
onClose?(): void
}
export type ModalResult = {
value: string;
};
value: string
}
const Modal: React.FC<ModalOptions & Props> = props => {
const {
mode = 'confirm',
title = t('general.tip'),
text = '',
input = '',
placeholder = '',
inputType = 'text',
inputMode,
type = 'default',
showHeader = true,
center = false,
okButtonText = t('general.confirm'),
okButtonType = 'primary',
cancelButtonText = t('general.cancel'),
cancelButtonType = 'secondary',
flexFooter = false,
footer,
show,
onClose,
onDismiss,
id,
validator,
onConfirm,
children,
choices,
dangerousHTML: html,
} = props;
const Modal: React.FC<ModalOptions & Props> = (props) => {
const {
mode = 'confirm',
title = t('general.tip'),
text = '',
input = '',
placeholder = '',
inputType = 'text',
inputMode,
type = 'default',
showHeader = true,
center = false,
okButtonText = t('general.confirm'),
okButtonType = 'primary',
cancelButtonText = t('general.cancel'),
cancelButtonType = 'secondary',
flexFooter = false,
} = props
const [value, setValue] = useState(input);
const [valid, setValid] = useState(true);
const [validatorMessage, setValidatorMessage] = useState('');
const reference = useRef<HTMLDivElement>(null);
const [modal, setModal] = useState<BootstrapModal>();
const [value, setValue] = useState(input)
const [valid, setValid] = useState(true)
const [validatorMessage, setValidatorMessage] = useState('')
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!reference.current) {
return;
}
const { show } = props
const _modal = new BootstrapModal(reference.current);
setModal(_modal);
useEffect(() => {
if (!show) {
return
}
return () => {
_modal.dispose();
};
}, [reference]);
const onHidden = () => props.onClose?.()
useEffect(() => {
if (!show) {
return;
}
const el = $(ref.current!)
el.on('hidden.bs.modal', onHidden)
const onHidden = () => {
onClose?.();
};
return () => {
el.off('hidden.bs.modal', onHidden)
}
}, [show, props.onClose])
const element = reference.current;
if (!element) {
return;
}
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setValue(event.target.value)
}
element.addEventListener('hidden.bs.modal', onHidden);
const confirm = () => {
const { validator } = props
if (typeof validator === 'function') {
const result = validator(value)
if (typeof result === 'string') {
setValidatorMessage(result)
setValid(false)
return
}
}
return () => {
element.removeEventListener('hidden.bs.modal', onHidden);
};
}, [reference, show, onClose]);
props.onConfirm?.({ value })
$(ref.current!).modal('hide')
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setValue(event.target.value);
};
// The "hidden.bs.modal" event can't be trigged automatically when testing.
/* istanbul ignore next */
if (process.env.NODE_ENV === 'test') {
$(ref.current!).trigger('hidden.bs.modal')
}
}
const confirm = () => {
if (typeof validator === 'function') {
const result = validator(value);
if (typeof result === 'string') {
setValidatorMessage(result);
setValid(false);
return;
}
}
const dismiss = () => {
props.onDismiss?.()
$(ref.current!).modal('hide')
onConfirm?.({value});
modal?.hide();
/* istanbul ignore next */
if (process.env.NODE_ENV === 'test') {
$(ref.current!).trigger('hidden.bs.modal')
}
}
// The "hidden.bs.modal" event can't be trigged automatically when testing.
useEffect(() => {
if (show) {
setTimeout(() => $(ref.current!).modal('show'), 50)
}
}, [show])
if (import.meta.env.NODE_ENV === 'test') {
$(reference.current!).trigger('hidden.bs.modal');
}
};
if (!show) {
return null
}
const dismiss = () => {
onDismiss?.();
modal?.hide();
return (
<div id={props.id} className="modal fade" role="dialog" ref={ref}>
<div
className={`modal-dialog ${center ? 'modal-dialog-centered' : ''}`}
role="document"
>
<div className={`modal-content bg-${type}`}>
<ModalHeader show={showHeader} title={title} onDismiss={dismiss} />
<ModalBody
text={text}
dangerousHTML={props.dangerousHTML}
showInput={mode === 'prompt'}
value={value}
choices={props.choices}
onChange={handleInputChange}
inputType={inputType}
inputMode={inputMode}
placeholder={placeholder}
invalid={!valid}
validatorMessage={validatorMessage}
>
{props.children}
</ModalBody>
<ModalFooter
showCancelButton={mode !== 'alert'}
flexFooter={flexFooter}
okButtonType={okButtonType}
okButtonText={okButtonText}
cancelButtonType={cancelButtonType}
cancelButtonText={cancelButtonText}
onConfirm={confirm}
onDismiss={dismiss}
>
{props.footer}
</ModalFooter>
</div>
</div>
</div>
)
}
if (import.meta.env.NODE_ENV === 'test') {
$(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;
export default Modal

View File

@ -1,25 +1,29 @@
import React from 'react'
import ModalContent from './ModalContent'
import ModalInput from './ModalInput'
import type { Props as ContentProps } from './ModalContent'
import type {
Props as InputProps,
InternalProps as InputInteralProps,
} from './ModalInput'
import ModalContent, {type Props as ContentProps} from './ModalContent';
import ModalInput, {
type
InternalProps as InputInteralProps,
type
Props as InputProps,
} from './ModalInput';
interface InternalProps {
showInput: boolean
}
type InternalProps = {
readonly showInput: boolean;
};
export type Props = ContentProps & InputProps
export type Props = ContentProps & InputProps;
const ModalBody: React.FC<InternalProps & InputInteralProps & Props> = (
props,
) => {
return (
<div className="modal-body">
<ModalContent text={props.text} dangerousHTML={props.dangerousHTML}>
{props.children}
</ModalContent>
{props.showInput && <ModalInput {...props} />}
</div>
)
}
const ModalBody: React.FC<InternalProps & InputInteralProps & Props> = props => (
<div className='modal-body'>
<ModalContent text={props.text} dangerousHTML={props.dangerousHTML}>
{props.children}
</ModalContent>
{props.showInput && <ModalInput {...props}/>}
</div>
);
export default ModalBody;
export default ModalBody

View File

@ -1,29 +1,26 @@
import React from 'react'
export type Props = {
readonly text?: string;
readonly dangerousHTML?: string;
readonly children?: React.ReactNode;
};
export interface Props {
text?: string
dangerousHTML?: string
}
const ModalContent: React.FC<Props> = props => {
if (props.children) {
return <>{props.children}</>;
}
const ModalContent: React.FC<Props> = (props) => {
if (props.children) {
return <>{props.children}</>
} else if (props.text) {
return (
<>
{props.text.split(/\r?\n/).map((line, i) => (
<p key={i}>{line}</p>
))}
</>
)
} else if (props.dangerousHTML) {
return <div dangerouslySetInnerHTML={{ __html: props.dangerousHTML }} />
}
if (props.text) {
return (
<>
{props.text.split(/\r?\n/).map((line, i) =>
<p key={i}>{line}</p>)}
</>
);
}
return <></>
}
if (props.dangerousHTML) {
return <div dangerouslySetInnerHTML={{__html: props.dangerousHTML}}/>;
}
return <></>;
};
export default ModalContent;
export default ModalContent

View File

@ -1,50 +1,49 @@
import React from 'react'
export type Props = {
readonly flexFooter?: boolean;
readonly okButtonText?: string;
readonly okButtonType?: string;
readonly cancelButtonText?: string;
readonly cancelButtonType?: string;
readonly children?: React.ReactNode;
};
export interface Props {
flexFooter?: boolean
okButtonText?: string
okButtonType?: string
cancelButtonText?: string
cancelButtonType?: string
}
type InternalProps = {
readonly showCancelButton: boolean;
onConfirm?: () => void;
onDismiss?: () => void;
};
interface InternalProps {
showCancelButton: boolean
onConfirm?(): void
onDismiss?(): void
}
const ModalFooter: React.FC<InternalProps & Props> = props => {
const classes = ['modal-footer'];
if (props.flexFooter) {
classes.push('d-flex', 'justify-content-between');
}
const ModalFooter: React.FC<InternalProps & Props> = (props) => {
const classes = ['modal-footer']
if (props.flexFooter) {
classes.push('d-flex', 'justify-content-between')
}
const footerClass = classes.join(' ')
const footerClass = classes.join(' ');
return props.children ? (
<div className={footerClass}>{props.children}</div>
) : (
<div className={footerClass}>
{props.showCancelButton && (
<button
type="button"
className={`btn btn-${props.cancelButtonType}`}
data-dismiss="modal"
onClick={props.onDismiss}
>
{props.cancelButtonText}
</button>
)}
<button
type="button"
className={`btn btn-${props.okButtonType}`}
onClick={props.onConfirm}
>
{props.okButtonText}
</button>
</div>
)
}
return props.children
? <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;
export default ModalFooter

View File

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

View File

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

View File

@ -1,122 +1,124 @@
import React from 'react'
import { t } from '@/scripts/i18n'
import PaginationItem from './PaginationItem'
import {t} from '@/scripts/i18n';
import PaginationItem from './PaginationItem';
type Props = {
readonly page: number;
readonly totalPages: number;
onChange: (page: number) => void | Promise<void>;
};
interface Props {
page: number
totalPages: number
onChange(page: number): void | Promise<void>
}
const labels = {
prev: '',
next: '',
};
prev: '',
next: '',
}
const Pagination: React.FC<Props> = props => {
const {page, totalPages, onChange} = props;
const Pagination: React.FC<Props> = (props) => {
const { page, totalPages, onChange } = props
if (totalPages < 1) {
return null;
}
if (totalPages < 1) {
return null
}
return (
<ul className='pagination'>
<PaginationItem
title={t('vendor.datatable.prev')}
disabled={page === 1}
onClick={async () => onChange(page - 1)}
>
{labels.prev}
<span className='d-inline d-sm-none ml-1'>
{t('vendor.datatable.prev')}
</span>
</PaginationItem>
{totalPages < 8
? Array.from({length: totalPages}).map((_, i) => (
<PaginationItem
key={i}
className='d-none d-sm-block'
active={page === i + 1}
onClick={async () => onChange(i + 1)}
>
{i + 1}
</PaginationItem>
))
: (
<>
{page < 4
? [1, 2, 3, 4].map(n => (
<PaginationItem
key={n}
className='d-none d-sm-block'
active={page === n}
onClick={async () => onChange(n)}
>
{n}
</PaginationItem>
))
: (
<PaginationItem
className='d-none d-sm-block'
onClick={async () => onChange(1)}
>
1
</PaginationItem>
)}
<PaginationItem disabled className='d-none d-sm-block'>
...
</PaginationItem>
{page > 3 && page < totalPages - 2 && (
<>
{[page - 1, page, page + 1].map(n => (
<PaginationItem
key={n}
className='d-none d-sm-block'
active={page === n}
onClick={async () => onChange(n)}
>
{n}
</PaginationItem>
))}
<PaginationItem disabled className='d-none d-sm-block'>
...
</PaginationItem>
</>
)}
{totalPages - page < 3
? [totalPages - 3, totalPages - 2, totalPages - 1, totalPages].map(n => (
<PaginationItem
key={n}
className='d-none d-sm-block'
active={page === n}
onClick={async () => onChange(n)}
>
{n}
</PaginationItem>
))
: (
<PaginationItem
className='d-none d-sm-block'
onClick={async () => onChange(totalPages)}
>
{totalPages}
</PaginationItem>
)}
</>
)}
<PaginationItem
title={t('vendor.datatable.next')}
disabled={page === totalPages}
onClick={async () => onChange(page + 1)}
>
<span className='d-inline d-sm-none mr-1'>
{t('vendor.datatable.next')}
</span>
{labels.next}
</PaginationItem>
</ul>
);
};
return (
<ul className="pagination">
<PaginationItem
title={t('vendor.datatable.prev')}
disabled={page === 1}
onClick={() => onChange(page - 1)}
>
{labels.prev}
<span className="d-inline d-sm-none ml-1">
{t('vendor.datatable.prev')}
</span>
</PaginationItem>
{totalPages < 8 ? (
Array.from({ length: totalPages }).map((_, i) => (
<PaginationItem
key={i}
className="d-none d-sm-block"
active={page === i + 1}
onClick={() => onChange(i + 1)}
>
{i + 1}
</PaginationItem>
))
) : (
<>
{page < 4 ? (
[1, 2, 3, 4].map((n) => (
<PaginationItem
key={n}
className="d-none d-sm-block"
active={page === n}
onClick={() => onChange(n)}
>
{n}
</PaginationItem>
))
) : (
<PaginationItem
className="d-none d-sm-block"
onClick={() => onChange(1)}
>
1
</PaginationItem>
)}
<PaginationItem className="d-none d-sm-block" disabled>
...
</PaginationItem>
{page > 3 && page < totalPages - 2 && (
<>
{[page - 1, page, page + 1].map((n) => (
<PaginationItem
key={n}
className="d-none d-sm-block"
active={page === n}
onClick={() => onChange(n)}
>
{n}
</PaginationItem>
))}
<PaginationItem className="d-none d-sm-block" disabled>
...
</PaginationItem>
</>
)}
{totalPages - page < 3 ? (
[totalPages - 3, totalPages - 2, totalPages - 1, totalPages].map(
(n) => (
<PaginationItem
key={n}
className="d-none d-sm-block"
active={page === n}
onClick={() => onChange(n)}
>
{n}
</PaginationItem>
),
)
) : (
<PaginationItem
className="d-none d-sm-block"
onClick={() => onChange(totalPages)}
>
{totalPages}
</PaginationItem>
)}
</>
)}
<PaginationItem
title={t('vendor.datatable.next')}
disabled={page === totalPages}
onClick={() => onChange(page + 1)}
>
<span className="d-inline d-sm-none mr-1">
{t('vendor.datatable.next')}
</span>
{labels.next}
</PaginationItem>
</ul>
)
}
export default Pagination;
export default Pagination

View File

@ -1,41 +1,39 @@
import React from 'react'
type Props = {
readonly disabled?: boolean;
readonly active?: boolean;
readonly title?: string;
readonly className?: string;
onClick?: () => void;
readonly children?: React.ReactNode;
};
interface Props {
disabled?: boolean
active?: boolean
title?: string
className?: string
onClick?(): void
}
const PaginationItem: React.FC<Props> = props => {
const classes = ['page-item'];
if (props.active) {
classes.push('active');
}
const PaginationItem: React.FC<Props> = (props) => {
const classes = ['page-item']
if (props.active) {
classes.push('active')
}
if (props.disabled) {
classes.push('disabled')
}
if (props.className) {
classes.push(props.className)
}
if (props.disabled) {
classes.push('disabled');
}
const handleClick = (event: React.MouseEvent) => {
event.preventDefault()
if (!props.disabled && props.onClick) {
props.onClick()
}
}
if (props.className) {
classes.push(props.className);
}
return (
<li className={classes.join(' ')} title={props.title} onClick={handleClick}>
<a href="#" className="page-link" aria-disabled={props.disabled}>
{props.children}
</a>
</li>
)
}
const handleClick = (event: React.MouseEvent) => {
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;
export default PaginationItem

View File

@ -1,21 +1,21 @@
import {css} from '@emotion/react';
import {useEffect, useState} from 'react';
/** @jsxImportSource @emotion/react */
import React, { useState, useEffect } from 'react'
import { css } from '@emotion/react'
export type ToastType = 'success' | 'info' | 'warning' | 'error';
export type ToastType = 'success' | 'info' | 'warning' | 'error'
type Props = {
readonly type: ToastType;
readonly distance: number;
onClose: () => void | Promise<void>;
readonly children: React.ReactNode;
};
interface Props {
type: ToastType
distance: number
onClose(): void | Promise<void>
}
const icons = new Map<ToastType, string>([
['success', 'check'],
['info', 'info'],
['warning', 'exclamation-triangle'],
['error', 'times-circle'],
]);
['success', 'check'],
['info', 'info'],
['warning', 'exclamation-triangle'],
['error', 'times-circle'],
])
const wrapper = css`
position: fixed;
@ -24,54 +24,52 @@ const wrapper = css`
z-index: 1050;
transition-property: top;
transition-duration: 0.3s;
`;
`
const shadow = css`
box-shadow: 0 0.25rem 0.75rem rgba(0, 0, 0, 0.1);
`;
`
const Toast: React.FC<Props> = props => {
const [show, setShow] = useState(false);
const Toast: React.FC<Props> = (props) => {
const [show, setShow] = useState(false)
useEffect(() => {
const timer = setTimeout(() => {
setShow(true);
}, 100);
useEffect(() => {
const timer = setTimeout(() => setShow(true), 100)
return () => {
clearTimeout(timer);
};
}, [props.onClose]);
return () => {
clearTimeout(timer)
}
}, [props.onClose])
const type = props.type === 'error' ? 'danger' : props.type;
const type = props.type === 'error' ? 'danger' : props.type
const classes = [
`alert alert-${type}`,
'd-flex justify-content-between',
'fade',
];
if (show) {
classes.push('show');
}
const classes = [
`alert alert-${type}`,
'd-flex justify-content-between',
'fade',
]
if (show) {
classes.push('show')
}
const role = type === 'success' || type === 'info' ? 'status' : 'alert';
const role = type === 'success' || type === 'info' ? 'status' : 'alert'
return (
<div css={wrapper} style={{top: `${props.distance}px`}}>
<div className={classes.join(' ')} css={shadow} role={role}>
<span className='mr-1 d-flex align-items-center'>
<i className={`icon fas fa-${icons.get(props.type)}`}/>
</span>
<span>{props.children}</span>
<button
type='button'
className='mr-2 ml-1 close'
onClick={props.onClose}
>
&times;
</button>
</div>
</div>
);
};
return (
<div css={wrapper} style={{ top: `${props.distance}px` }}>
<div className={classes.join(' ')} css={shadow} role={role}>
<span className="mr-1 d-flex align-items-center">
<i className={`icon fas fa-${icons.get(props.type)}`}></i>
</span>
<span>{props.children}</span>
<button
type="button"
className="mr-2 ml-1 close"
onClick={props.onClose}
>
&times;
</button>
</div>
</div>
)
}
export default Toast;
export default Toast

View File

@ -1,38 +1,38 @@
import {t} from '@/scripts/i18n';
import * as breakpoints from '@/styles/breakpoints';
import * as cssUtils from '@/styles/utils';
import {css} from '@emotion/react';
import styled from '@emotion/styled';
import {useEffect, useRef, useState} from 'react';
import {useMeasure} from 'react-use';
import * as skinview3d from 'skinview3d';
import bg1 from '../../../misc/backgrounds/1.webp';
import bg2 from '../../../misc/backgrounds/2.webp';
import bg3 from '../../../misc/backgrounds/3.webp';
import bg4 from '../../../misc/backgrounds/4.webp';
import bg5 from '../../../misc/backgrounds/5.webp';
import bg6 from '../../../misc/backgrounds/6.webp';
import bg7 from '../../../misc/backgrounds/7.webp';
import SkinSteve from '../../../misc/textures/steve.png';
/** @jsxImportSource @emotion/react */
import React, { useState, useEffect, useRef } from 'react'
import { useMeasure } from 'react-use'
import { css } from '@emotion/react'
import styled from '@emotion/styled'
import * as skinview3d from 'skinview3d'
import { t } from '@/scripts/i18n'
import * as cssUtils from '@/styles/utils'
import * as breakpoints from '@/styles/breakpoints'
import SkinSteve from '../../../misc/textures/steve.png'
import bg1 from '../../../misc/backgrounds/1.webp'
import bg2 from '../../../misc/backgrounds/2.webp'
import bg3 from '../../../misc/backgrounds/3.webp'
import bg4 from '../../../misc/backgrounds/4.webp'
import bg5 from '../../../misc/backgrounds/5.webp'
import bg6 from '../../../misc/backgrounds/6.webp'
import bg7 from '../../../misc/backgrounds/7.webp'
const backgrounds = [bg1, bg2, bg3, bg4, bg5, bg6, bg7];
export const PICTURES_COUNT = backgrounds.length;
const backgrounds = [bg1, bg2, bg3, bg4, bg5, bg6, bg7]
export const PICTURES_COUNT = backgrounds.length
type Props = {
readonly skin?: string;
readonly cape?: string;
readonly children?: React.ReactNode;
readonly isAlex: boolean;
readonly showIndicator?: boolean;
readonly initPositionZ?: number;
};
interface Props {
skin?: string
cape?: string
isAlex: boolean
showIndicator?: boolean
initPositionZ?: number
}
const animationFactories = [
() => new skinview3d.WalkingAnimation(),
() => new skinview3d.RunningAnimation(),
() => new skinview3d.FlyingAnimation(),
() => new skinview3d.IdleAnimation(),
];
() => new skinview3d.WalkingAnimation(),
() => new skinview3d.RunningAnimation(),
() => new skinview3d.FlyingAnimation(),
() => new skinview3d.IdleAnimation(),
]
const ActionButton = styled.i`
display: inline;
@ -41,7 +41,7 @@ const ActionButton = styled.i`
color: #555;
cursor: pointer;
}
`;
`
const cssViewer = css`
flex: 1 1 auto;
@ -56,255 +56,251 @@ const cssViewer = css`
display: flex;
justify-content: center;
}
`;
`
const Viewer: React.FC<Props> = props => {
const {initPositionZ = 70} = props;
const Viewer: React.FC<Props> = (props) => {
const { initPositionZ = 70 } = props
const viewReference: React.MutableRefObject<skinview3d.SkinViewer> = useRef(null!);
const containerReference = useRef<HTMLCanvasElement>(null);
const viewRef: React.MutableRefObject<skinview3d.SkinViewer> = useRef(null!)
const containerRef = useRef<HTMLCanvasElement>(null)
const [paused, setPaused] = useState(false);
const [animation, setAnimation] = useState(0);
const [bgPicture, setBgPicture] = useState(-1);
const [paused, setPaused] = useState(false)
const [animation, setAnimation] = useState(0)
const [bgPicture, setBgPicture] = useState(-1)
const indicator = (() => {
const {skin, cape} = props;
if (skin && cape) {
return `${t('general.skin')} & ${t('general.cape')}`;
}
const indicator = (() => {
const { skin, cape } = props
if (skin && cape) {
return `${t('general.skin')} & ${t('general.cape')}`
} else if (skin) {
return t('general.skin')
} else if (cape) {
return t('general.cape')
}
return ''
})()
if (skin) {
return t('general.skin');
}
useEffect(() => {
const container = containerRef.current!
const viewer = new skinview3d.SkinViewer({
canvas: container,
width: container.clientWidth,
height: container.clientHeight,
skin: props.skin || SkinSteve,
cape: props.cape || undefined,
model: props.isAlex ? 'slim' : 'default',
zoom: initPositionZ / 100,
})
viewer.autoRotate = true
if (cape) {
return t('general.cape');
}
if (document.body.classList.contains('dark-mode')) {
viewer.background = '#6c757d'
}
return '';
})();
viewRef.current = viewer
useEffect(() => {
const container = containerReference.current!;
const viewer = new skinview3d.SkinViewer({
canvas: container,
width: container.clientWidth,
height: container.clientHeight,
skin: props.skin || SkinSteve,
cape: props.cape || undefined,
model: props.isAlex ? 'slim' : 'default',
zoom: initPositionZ / 100,
});
viewer.autoRotate = true;
return () => {
viewer.dispose()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
if (document.body.classList.contains('dark-mode')) {
viewer.background = '#6c757d';
}
const [containerWrapperRef, containerMeasure] = useMeasure<HTMLDivElement>()
useEffect(() => {
viewRef.current.setSize(containerMeasure.width, containerMeasure.height)
})
viewReference.current = viewer;
useEffect(() => {
const viewer = viewRef.current
viewer.loadSkin(props.skin || SkinSteve, {
model: props.isAlex ? 'slim' : 'default',
})
}, [props.skin, props.isAlex])
return () => {
viewer.dispose();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const viewer = viewRef.current
if (props.cape) {
viewer.loadCape(props.cape)
} else {
viewer.resetCape()
}
}, [props.cape])
const [containerWrapperReference, containerMeasure] = useMeasure<HTMLDivElement>();
useEffect(() => {
viewReference.current.setSize(containerMeasure.width, containerMeasure.height);
});
useEffect(() => {
const viewer = viewRef.current
const factory = animationFactories[animation]
if (factory === undefined) {
viewer.animation = null
} else {
const newAnimation = factory()
newAnimation.paused = paused // Perseve `paused` state
viewer.animation = newAnimation
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [animation])
useEffect(() => {
const viewer = viewReference.current;
viewer.loadSkin(props.skin || SkinSteve, {
model: props.isAlex ? 'slim' : 'default',
});
}, [props.skin, props.isAlex]);
useEffect(() => {
const currentAnimation = viewRef.current.animation
if (currentAnimation !== null) {
currentAnimation.paused = paused
}
}, [paused])
useEffect(() => {
const viewer = viewReference.current;
if (props.cape) {
viewer.loadCape(props.cape);
} else {
viewer.resetCape();
}
}, [props.cape]);
useEffect(() => {
const viewer = viewRef.current
const backgroundUrl = backgrounds[bgPicture]
if (backgroundUrl === undefined) {
viewer.background = null
} else {
viewer.loadBackground(backgroundUrl)
}
}, [bgPicture])
useEffect(() => {
const viewer = viewReference.current;
const factory = animationFactories[animation];
if (factory === undefined) {
viewer.animation = null;
} else {
const newAnimation = factory();
newAnimation.paused = paused; // Perseve `paused` state
viewer.animation = newAnimation;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [animation]);
const togglePause = () => {
setPaused((paused) => {
if (paused) {
return false
} else {
viewRef.current.autoRotate = false
return true
}
})
}
useEffect(() => {
const currentAnimation = viewReference.current.animation;
if (currentAnimation !== null) {
currentAnimation.paused = paused;
}
}, [paused]);
const toggleAnimation = () => {
setAnimation((index) => (index + 1) % animationFactories.length)
setPaused(false)
}
useEffect(() => {
const viewer = viewReference.current;
const backgroundUrl = backgrounds[bgPicture];
if (backgroundUrl === undefined) {
viewer.background = null;
} else {
viewer.loadBackground(backgroundUrl);
}
}, [bgPicture]);
const toggleRotate = () => {
const viewer = viewRef.current
viewer.autoRotate = !viewer.autoRotate
}
const togglePause = () => {
setPaused(paused => {
if (paused) {
return false;
}
const toggleBackEquippment = () => {
const player = viewRef.current.playerObject
if (player.backEquipment === 'cape') {
player.backEquipment = 'elytra'
} else {
player.backEquipment = 'cape'
}
}
viewReference.current.autoRotate = false;
return true;
});
};
const setWhite = () => {
viewRef.current.background = '#fff'
}
const setGray = () => {
viewRef.current.background = '#6c757d'
}
const setBlack = () => {
viewRef.current.background = '#000'
}
const setPrevPicture = () => {
setBgPicture((index) => {
if (bgPicture <= 0) {
return PICTURES_COUNT - 1
} else {
return index - 1
}
})
}
const setNextPicture = () => {
setBgPicture((index) => {
if (bgPicture >= PICTURES_COUNT - 1) {
return 0
} else {
return index + 1
}
})
}
const toggleAnimation = () => {
setAnimation(index => (index + 1) % animationFactories.length);
setPaused(false);
};
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>
<ActionButton
className={`fas fa-person-running`}
data-toggle="tooltip"
data-placement="bottom"
title={t('general.switchAnimation')}
onClick={toggleAnimation}
></ActionButton>
<ActionButton
className={`fas fa-${paused ? 'play' : 'pause'}`}
data-toggle="tooltip"
data-placement="bottom"
title={
paused
? t('general.playAnimation')
: t('general.pauseAnimation')
}
onClick={togglePause}
></ActionButton>
<ActionButton
className="fas fa-rotate-right"
data-toggle="tooltip"
data-placement="bottom"
title={t('general.rotation')}
onClick={toggleRotate}
></ActionButton>
</div>
</div>
</div>
<div ref={containerWrapperRef} css={cssViewer} className="p-0">
<canvas ref={containerRef}></canvas>
</div>
<div className="card-footer">
<div className="mt-2 mb-3 d-flex">
<div
className="btn-color bg-white rounded-pill mr-2 elevation-2"
title={t('colors.white')}
onClick={setWhite}
/>
<div
className="btn-color bg-black rounded-pill mr-2 elevation-2"
title={t('colors.black')}
onClick={setBlack}
/>
<div
className="btn-color bg-gray rounded-pill mr-2 elevation-2"
title={t('colors.gray')}
onClick={setGray}
/>
<div
className="btn-color bg-green rounded-pill mr-2 elevation-2"
css={cssUtils.center}
title={t('colors.prev')}
onClick={setPrevPicture}
>
<i className="fas fa-arrow-left"></i>
</div>
<div
className="btn-color bg-green rounded-pill mr-2 elevation-2"
css={cssUtils.center}
title={t('colors.next')}
onClick={setNextPicture}
>
<i className="fas fa-arrow-right"></i>
</div>
</div>
{props.children}
</div>
</div>
)
}
const toggleRotate = () => {
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;
export default Viewer

View File

@ -1,17 +1,17 @@
import {t} from '@/scripts/i18n';
import React from 'react'
import { t } from '@/scripts/i18n'
export default function ViewerSkeleton() {
return (
<div className='card'>
<div className='card-header'>
<div className='d-flex justify-content-between'>
<h3 className='card-title'>
<span>{t('general.texturePreview')}</span>
</h3>
</div>
</div>
<div className='card-body'/>
</div>
);
}
const ViewerSkeleton: React.FC = () => (
<div className="card">
<div className="card-header">
<div className="d-flex justify-content-between">
<h3 className="card-title">
<span>{t('general.texturePreview')}</span>
</h3>
</div>
</div>
<div className="card-body"></div>
</div>
)
export default ViewerSkeleton

View File

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

View File

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

View File

@ -0,0 +1,140 @@
import React, { useEffect, useRef } from 'react'
import ReactDOM from 'react-dom'
import styled from '@emotion/styled'
import { Terminal } from 'xterm'
import { FitAddon } from 'xterm-addon-fit'
import { Shell, Stdio } from 'blessing-skin-shell'
import 'xterm/css/xterm.css'
import Draggable from 'react-draggable'
import * as event from './event'
import AptCommand from './cli/AptCommand'
import ClosetCommand from './cli/ClosetCommand'
import DnfCommand from './cli/DnfCommand'
import PacmanCommand from './cli/PacmanCommand'
import RmCommand from './cli/RmCommand'
import * as breakpoints from '@/styles/breakpoints'
let launched = false
const TerminalContainer = styled.div`
z-index: 1040;
position: fixed;
bottom: 7vh;
user-select: none;
.card-body {
background-color: #000;
}
${breakpoints.greaterThan(breakpoints.Breakpoint.xl)} {
left: 25vw;
width: 50vw;
height: 50vh;
}
${breakpoints.between(breakpoints.Breakpoint.md, breakpoints.Breakpoint.xl)} {
left: 5vw;
width: 90vw;
height: 40vh;
}
${breakpoints.lessThan(breakpoints.Breakpoint.md)} {
left: 1vw;
width: 98vw;
height: 35vh;
}
`
const TerminalWindow: React.FC<{ onClose(): void }> = (props) => {
const mount = useRef<HTMLDivElement>(null)
useEffect(() => {
const el = mount.current
if (!el) {
return
}
const terminal = new Terminal()
const fitAddon = new FitAddon()
terminal.loadAddon(fitAddon)
terminal.setOption(
'fontFamily',
'Monaco, Consolas, "Roboto Mono", "Noto Sans", "Droid Sans Mono"',
)
terminal.open(el)
fitAddon.fit()
const programs = new Map<string, (stdio: Stdio, args: string[]) => void>()
programs.set('apt', AptCommand)
programs.set('closet', ClosetCommand)
programs.set('dnf', DnfCommand)
programs.set('pacman', PacmanCommand)
programs.set('rm', RmCommand)
event.emit('registerCLIPrograms', programs)
const shell = new Shell(terminal)
programs.forEach((program, name) => {
shell.addExternal(name, program)
})
const originalLogger = console.log
console.log = (data: string, ...args: any[]) => {
const stack = new Error().stack
if (stack?.includes('outputHelp')) {
terminal.writeln(data.replace(/\n/g, '\r\n'))
} else {
originalLogger(data, ...args)
}
}
const unbindData = terminal.onData((e) => shell.input(e))
const unbindKey = terminal.onKey((e) =>
event.emit('terminalKeyPress', e.key),
)
launched = true
return () => {
unbindData.dispose()
unbindKey.dispose()
shell.free()
fitAddon.dispose()
terminal.dispose()
console.log = originalLogger
launched = false
}
}, [])
return (
<Draggable handle=".card-header">
<TerminalContainer className="card">
<div className="card-header">
<div className="d-flex justify-content-between">
<h4 className="card-title d-flex align-items-center">
Blessing Skin Shell
</h4>
<button className="btn btn-default" onClick={props.onClose}>
&times;
</button>
</div>
</div>
<div className="card-body p-2" ref={mount}></div>
</TerminalContainer>
</Draggable>
)
}
export function launch() {
if (launched) {
return
}
const container = document.createElement('div')
document.body.appendChild(container)
const handleClose = () => {
ReactDOM.unmountComponentAtNode(container)
container.remove()
}
ReactDOM.render(<TerminalWindow onClose={handleClose} />, container)
}

View File

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

View File

@ -0,0 +1,23 @@
import type { Stdio } from 'blessing-skin-shell'
import cac from 'cac'
import { install, remove } from './pluginManager'
export default async function apt(stdio: Stdio, args: string[]) {
const program = cac('apt')
program.help()
program
.command('install <plugin>', 'install a new plugin')
.action((plugin: string) => install(plugin, stdio))
program
.command('upgrade <plugin>', 'upgrade an existed plugin')
.action((plugin: string) => install(plugin, stdio))
program
.command('remove <plugin>', 'remove a plugin')
.action((plugin: string) => remove(plugin, stdio))
program.parse(['', ''].concat(args), { run: false })
await program.runMatchedCommand()
}

View File

@ -0,0 +1,46 @@
import type { Stdio } from 'blessing-skin-shell'
import cac from 'cac'
import * as fetch from '../net'
import type { User, Texture } from '../types'
type Response = fetch.ResponseBody<{ user: User; texture: Texture }>
export default async function closet(stdio: Stdio, args: string[]) {
const program = cac('closet')
program.help()
program
.command('add <uid> <tid>', "add texture to someone's closet")
.action(async (uid: string, tid: string) => {
const { code, data } = await fetch.post<Response>(
`/admin/closet/${uid}`,
{ tid },
)
if (code === 0) {
const { texture, user } = data
stdio.println(
`Texture "${texture.name}" was added to user ${user.nickname}'s closet.`,
)
} else {
stdio.println('Error occurred.')
}
})
program
.command('remove <uid> <tid>', "remove texture from someone's closet")
.action(async (uid: string, tid: string) => {
const { code, data } = await fetch.del<Response>(`/admin/closet/${uid}`, {
tid,
})
if (code === 0) {
const { texture, user } = data
stdio.println(
`Texture "${texture.name}" was removed from user ${user.nickname}'s closet.`,
)
} else {
stdio.println('Error occurred.')
}
})
program.parse(['', ''].concat(args), { run: false })
await program.runMatchedCommand()
}

View File

@ -0,0 +1,23 @@
import type { Stdio } from 'blessing-skin-shell'
import cac from 'cac'
import { install, remove } from './pluginManager'
export default async function dnf(stdio: Stdio, args: string[]) {
const program = cac('dnf')
program.help()
program
.command('install <plugin>', 'install a new plugin')
.action((plugin: string) => install(plugin, stdio))
program
.command('upgrade <plugin>', 'upgrade an existed plugin')
.action((plugin: string) => install(plugin, stdio))
program
.command('remove <plugin>', 'remove a plugin')
.action((plugin: string) => remove(plugin, stdio))
program.parse(['', ''].concat(args), { run: false })
await program.runMatchedCommand()
}

View File

@ -0,0 +1,31 @@
import type { Stdio } from 'blessing-skin-shell'
import cac from 'cac'
import { install, remove } from './pluginManager'
type Options = {
sync?: string
remove?: string
}
export default async function pacman(stdio: Stdio, args: string[]) {
if (args.length === 0) {
stdio.println('error: no operation specified (use -h for help)')
return
}
const program = cac('pacman')
program.help()
program.option('-S, --sync <plugin>', 'install or upgrade a plugin')
program.option('-R, --remove <plugin>', 'remove a plugin')
const { options } = program.parse(['', ''].concat(args), { run: false })
const opts: Options = options
/* istanbul ignore else */
if (opts.sync) {
await install(opts.sync, stdio)
} else if (opts.remove) {
await remove(opts.remove, stdio)
}
}

View File

@ -0,0 +1,40 @@
import type { Stdio } from 'blessing-skin-shell'
import cac from 'cac'
import * as fetch from '../net'
type Options = {
force?: boolean
recursive?: boolean
help?: boolean
}
export default async function rm(stdio: Stdio, args: string[]) {
const program = cac('rm')
program.help()
program
.command('<file>')
.option(
'-f, --force',
'ignore nonexistent files and arguments, never prompt',
)
.option(
'-r, --recursive',
'remove directories and their contents recursively',
)
.option('--no-preserve-root', "do not treat '/' specially")
const opts: Options = program.parse(['', ''].concat(args), {
run: false,
}).options
const path = program.args[0]
if (!path && !opts.help) {
stdio.println('rm: missing operand')
stdio.println("Try 'rm --help' for more information.")
}
if (opts.force && opts.recursive && path?.startsWith('/')) {
await fetch.post('/admin/resource?clear-cache')
}
}

View File

@ -0,0 +1,28 @@
import type { Stdio } from 'blessing-skin-shell'
import spinners from 'cli-spinners/spinners.json'
const { dots } = spinners
export class Spinner {
private timerId = 0
private index = 0
constructor(private stdio: Stdio) {}
start(message = '') {
this.timerId = window.setInterval(() => {
this.index += 1
this.index %= dots.frames.length
this.stdio.reset()
this.stdio.print(`${dots.frames[this.index]} ${message}`)
}, dots.interval)
}
stop(message = '') {
clearInterval(this.timerId)
this.stdio.reset()
this.stdio.println(message)
this.stdio.print('\u001B[?25h')
}
}

View File

@ -0,0 +1,35 @@
import type { Stdio } from 'blessing-skin-shell'
import * as event from '../event'
/* istanbul ignore next */
export function hackStdin() {
if (process.env.NODE_ENV === 'test') {
return process.stdin
}
// @ts-ignore
return {
on(eventName: string, handler: (str: string, key: string) => void) {
if (eventName === 'keypress') {
this._off = event.on('terminalKeyPress', (key: string) => {
handler(key, key)
})
}
},
isTTY: true,
setRawMode() {},
removeListener() {
this._off()
},
} as NodeJS.ReadStream & { _off(): void }
}
/* istanbul ignore next */
export function hackStdout(stdio: Stdio) {
return {
write(msg: string) {
stdio.print(msg.replace(/\n/g, '\r\n'))
return true
},
} as NodeJS.WriteStream
}

View File

@ -0,0 +1,43 @@
import type { Stdio } from 'blessing-skin-shell'
import prompts from 'prompts'
import * as fetch from '../net'
import { hackStdout, hackStdin } from './configureStdio'
import { Spinner } from './Spinner'
export async function install(plugin: string, stdio: Stdio) {
const spinner = new Spinner(stdio)
spinner.start('Installing plugin...')
const { message, data } = await fetch.post<
fetch.ResponseBody<{ reason?: string[] } | undefined>
>('/admin/plugins/market/download', { name: plugin })
spinner.stop(` ${message}`)
const reasons = data?.reason
if (reasons) {
stdio.println(reasons.map((reason) => `- ${reason}`).join('\r\n'))
}
}
export async function remove(plugin: string, stdio: Stdio) {
const { confirm }: { confirm: boolean } = await prompts({
name: 'confirm',
type: 'confirm',
message: `Are you sure to remove plugin "${plugin}"?`,
stdin: hackStdin(),
stdout: hackStdout(stdio),
})
if (!confirm) {
return
}
const spinner = new Spinner(stdio)
spinner.start('Uninstalling plugin...')
const { message } = await fetch.post<fetch.ResponseBody>(
'/admin/plugins/manage',
{ action: 'delete', name: plugin },
)
spinner.stop(` ${message}`)
}

View File

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

View File

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

View File

@ -1,8 +1,9 @@
import EmailVerification from '@/views/widgets/EmailVerification';
import ReactDOM from 'react-dom';
import React from 'react'
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) {
ReactDOM.render(<EmailVerification/>, container);
ReactDOM.render(<EmailVerification />, container)
}

View File

@ -1,22 +1,20 @@
const bus = new Map<string | symbol, Set<(...args: any[]) => void>>();
/* eslint-disable @typescript-eslint/no-unnecessary-condition */
const bus = new Map<string | symbol, Set<CallableFunction>>()
export function on(event: string | symbol, listener: (...args: any[]) => void) {
if (!bus.has(event)) {
bus.set(event, new Set());
}
export function on(event: string | symbol, listener: CallableFunction) {
if (!bus.has(event)) {
bus.set(event, new Set())
}
const listeners = bus.get(event)!
listeners.add(listener)
const listeners = bus.get(event)!;
listeners.add(listener);
return () => {
listeners.delete(listener);
};
return () => {
listeners.delete(listener)
}
}
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> {
const jsonElement = document.querySelector('#blessing-extra');
if (jsonElement) {
return JSON.parse(jsonElement.textContent ?? '{}');
}
return {};
const jsonElement = document.querySelector('#blessing-extra')
/* istanbul ignore next */
if (jsonElement) {
return JSON.parse(jsonElement.textContent ?? '{}')
} else {
return {}
}
}
blessing.extra = getExtraData();
blessing.extra = getExtraData()

View File

@ -1,24 +1,25 @@
import {getExtraData} from './extra';
import { getExtraData } from './extra'
export function scrollHander() {
const header = document.querySelector('.navbar');
/* istanbul ignore else */
if (header) {
window.addEventListener('scroll', () => {
if (window.scrollY >= (window.innerHeight * 2) / 3) {
header.classList.remove('transparent');
} else {
header.classList.add('transparent');
}
});
}
const header = document.querySelector('.navbar')
/* istanbul ignore else */
if (header) {
window.addEventListener('scroll', () => {
if (window.scrollY >= (window.innerHeight * 2) / 3) {
header.classList.remove('transparent')
} else {
header.classList.add('transparent')
}
})
}
}
/* istanbul ignore next */
if (process.env.NODE_ENV !== 'test') {
const {transparent_navbar} = getExtraData() as {
transparent_navbar: boolean;
};
if (transparent_navbar) {
window.addEventListener('load', scrollHander);
}
const { transparent_navbar } = getExtraData() as {
transparent_navbar: boolean
}
if (transparent_navbar) {
window.addEventListener('load', scrollHander)
}
}

View File

@ -1,11 +1,11 @@
import {useEffect, useState} from 'react';
import { useState, useEffect } from 'react'
export default function useBlessingExtra<T>(key: string, defaultValue?: T): T {
const [value, setValue] = useState<T>(defaultValue!);
const [value, setValue] = useState<T>(defaultValue!)
useEffect(() => {
setValue(blessing.extra[key] as T);
}, [key]);
useEffect(() => {
setValue(blessing.extra[key] as T)
}, [key])
return value;
return value
}

View File

@ -1,8 +1,8 @@
import {useEffect} from 'react';
import {emit} from '../event';
import { useEffect } from 'react'
import { emit } from '../event'
export default function useEmitMounted() {
useEffect(() => {
emit('mounted');
}, []);
useEffect(() => {
emit('mounted')
}, [])
}

View File

@ -1,13 +1,13 @@
import {useEffect, useState} from 'react';
import { useState, useEffect } from 'react'
export default function useIsLargeScreen() {
const [isLarge, setIsLarge] = useState(false);
const [isLarge, setIsLarge] = useState(false)
useEffect(() => {
if (window.innerWidth >= 992) {
setIsLarge(true);
}
}, []);
useEffect(() => {
if (window.innerWidth >= 992) {
setIsLarge(true)
}
}, [])
return isLarge;
return isLarge
}

View File

@ -1,20 +1,20 @@
import {useEffect, useRef} from 'react';
import { useEffect, useRef } from 'react'
export default function useMount(selector: string): HTMLElement | undefined {
const container = useRef<HTMLDivElement | undefined>(null);
export default function useMount(selector: string): HTMLElement | null {
const container = useRef<HTMLDivElement | null>(null)
useEffect(() => {
const mount = document.querySelector(selector)!;
const div = document.createElement('div');
container.current = div;
useEffect(() => {
const mount = document.querySelector(selector)!
const div = document.createElement('div')
container.current = div
mount.append(div);
mount.appendChild(div)
return () => {
div.remove();
container.current = null;
};
}, [selector]);
return () => {
mount.removeChild(div)
container.current = null
}
}, [selector])
return container.current;
return container.current
}

View File

@ -1,27 +1,26 @@
import {useEffect, useState} from 'react';
import * as fetch from '../net';
import {type Texture, TextureType} from '../types';
import { useState, useEffect } from 'react'
import * as fetch from '../net'
import { Texture, TextureType } from '../types'
export default function useTexture() {
const [tid, setTid] = useState(0);
const [url, setUrl] = useState('');
const [type, setType] = useState(TextureType.Steve);
const [tid, setTid] = useState(0)
const [url, setUrl] = useState('')
const [type, setType] = useState(TextureType.Steve)
useEffect(() => {
if (tid <= 0) {
setUrl('');
return;
}
useEffect(() => {
if (tid <= 0) {
setUrl('')
return
}
const getTexture = async () => {
const {hash, type} = await fetch.get<Texture>(`/skinlib/info/${tid}`);
const getTexture = async () => {
const { hash, type } = await fetch.get<Texture>(`/skinlib/info/${tid}`)
setUrl(`${blessing.base_url}/textures/${hash}`);
setType(type);
};
setUrl(`${blessing.base_url}/textures/${hash}`)
setType(type)
}
getTexture()
}, [tid])
getTexture();
}, [tid]);
return [{url, type}, setTid] as const;
return [{ url, type }, setTid] as const
}

View File

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

View File

@ -1,34 +1,31 @@
type I18nTable = {
[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;
interface I18nTable {
[key: string]: string | I18nTable | undefined
}
Object.assign(window, {trans: t});
Object.assign(blessing, {t});
export function t(key: string, parameters = Object.create(null)): string {
const segments = key.split('.')
let temp = blessing.i18n as I18nTable | undefined
let result = ''
for (const segment of segments) {
/* istanbul ignore next */
const middle = temp?.[segment]
if (!middle) {
return key
}
if (typeof middle === 'string') {
result = middle
} else {
temp = middle
}
}
Object.keys(parameters).forEach(
(slot) => (result = result.replace(`:${slot}`, parameters[slot])),
)
return result
}
Object.assign(window, { trans: t })
Object.assign(blessing, { t })

View File

@ -1,12 +1,12 @@
declare let __webpack_public_path__: string;
declare const __blessing_public_path__: string;
declare let __webpack_public_path__: string
declare const __blessing_public_path__: string
if (import.meta.env.NODE_ENV === 'development') {
__webpack_public_path__ = __blessing_public_path__;
if (process.env.NODE_ENV === 'development') {
__webpack_public_path__ = __blessing_public_path__
} else {
const link = document.querySelector<HTMLLinkElement>('link#cdn-host');
const base = link?.href ?? blessing.base_url;
__webpack_public_path__ = `${base}/app/`;
const link = document.querySelector<HTMLLinkElement>('link#cdn-host')
const base = link?.href ?? blessing.base_url
__webpack_public_path__ = `${base}/app/`
}
export {};
export {}

View File

@ -1,22 +1,22 @@
import {t} from './i18n';
import {post} from './net';
import {showModal} from './notify';
import urls from './urls';
import { post } from './net'
import { t } from './i18n'
import { showModal } from './notify'
import urls from './urls'
export async function logout() {
try {
await showModal({
text: t('general.confirmLogout'),
center: true,
});
} catch {
return;
}
try {
await showModal({
text: t('general.confirmLogout'),
center: true,
})
} catch {
return
}
await post(urls.auth.logout());
window.location.href = blessing.base_url;
await post(urls.auth.logout())
window.location.href = blessing.base_url
}
const button = document.querySelector('#logout-button');
button?.addEventListener('click', logout);
const button = document.querySelector('#logout-button')
/* istanbul ignore next */
button?.addEventListener('click', logout)

View File

@ -1,26 +1,27 @@
import {createRoot} from 'react-dom/client';
import Modal, {type ModalOptions, type ModalResult} from '../components/Modal';
import React from 'react'
import ReactDOM from 'react-dom'
import Modal, { ModalOptions, ModalResult } from '../components/Modal'
export async function showModal(options: ModalOptions = {}): Promise<ModalResult> {
return new Promise((resolve, reject) => {
const container = document.createElement('div');
document.body.append(container);
const root = createRoot(container);
export function showModal(options: ModalOptions = {}): Promise<ModalResult> {
return new Promise((resolve, reject) => {
const container = document.createElement('div')
document.body.appendChild(container)
const handleClose = () => {
root.unmount();
container.remove();
};
const handleClose = () => {
ReactDOM.unmountComponentAtNode(container)
document.body.removeChild(container)
}
root.render((
<Modal
{...options}
show
center
onConfirm={resolve}
onDismiss={reject}
onClose={handleClose}
/>
));
});
ReactDOM.render(
<Modal
{...options}
show
center
onConfirm={resolve}
onDismiss={reject}
onClose={handleClose}
/>,
container,
)
})
}

View File

@ -1,162 +1,159 @@
import {emit} from './event';
import {t} from './i18n';
import {showModal} from './notify';
import { emit } from './event'
import { showModal } from './notify'
import { t } from './i18n'
export type ResponseBody<T = undefined> = {
code: number;
message: string;
data: T extends undefined ? never : T;
};
class HTTPError extends Error {
response: Response;
constructor(message: string, response: Response) {
super(message);
this.response = response;
}
export interface ResponseBody<T = null> {
code: number
message: string
data: T extends null ? never : T
}
const empty: Record<string, never> = Object.create(null);
class HTTPError extends Error {
response: Response
constructor(message: string, response: Response) {
super(message)
this.response = response
}
}
const empty = Object.create(null)
export const init: RequestInit = {
credentials: 'same-origin',
headers: new Headers({
Accept: 'application/json',
}),
};
credentials: 'same-origin',
headers: new Headers({
Accept: 'application/json',
}),
}
function retrieveToken() {
const csrfField = document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]');
return csrfField?.content || '';
const csrfField = document.querySelector<HTMLMetaElement>(
'meta[name="csrf-token"]',
)
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
return csrfField?.content || ''
}
export async function walkFetch(request: Request): Promise<any> {
request.headers.set('X-CSRF-TOKEN', retrieveToken());
request.headers.set('X-CSRF-TOKEN', retrieveToken())
try {
const response = await fetch(request);
const cloned = response.clone();
const body
= response.headers.get('Content-Type') === 'application/json'
? await response.json()
: await response.text();
if (response.ok) {
return body;
}
try {
const response = await fetch(request)
const cloned = response.clone()
const body =
response.headers.get('Content-Type') === 'application/json'
? await response.json()
: await response.text()
if (response.ok) {
return body
}
let message: string = body.message
let {message} = body;
if (response.status === 422) {
// Process validation errors from Laravel.
const {
errors,
}: {
message: string
errors: { [field: string]: string[] }
} = body
return {
code: 1,
message: Object.keys(errors).map((field) => errors[field]![0])[0],
}
} else if (response.status === 419) {
return showModal({
mode: 'alert',
text: t('general.csrf'),
})
} else if (response.status === 403 || response.status === 400) {
return showModal({
mode: 'alert',
text: message,
type: 'warning',
})
}
if (response.status === 422) {
// Process validation errors from Laravel.
const {
errors,
}: {
message: string;
errors: Record<string, string[]>;
} = body;
return {
code: 1,
message: Object.keys(errors).map(field => errors[field][0])[0],
};
}
if (body.exception && Array.isArray(body.trace)) {
const trace = (body.trace as Array<{ file: string; line: number }>)
.map((t, i) => `[${i + 1}] ${t.file}#L${t.line}`)
.join('<br>')
message = `${message}<br><details>${trace}</details>`
}
if (response.status === 419) {
return await showModal({
mode: 'alert',
text: t('general.csrf'),
});
}
throw new HTTPError(message || body, cloned)
} catch (error: any) {
emit('fetchError', error)
await showModal({
mode: 'alert',
title: t('general.fatalError'),
dangerousHTML: error.message,
type: 'danger',
okButtonType: 'outline-light',
})
if (response.status === 403 || response.status === 400) {
return await showModal({
mode: 'alert',
text: message,
type: 'warning',
});
}
if (body.exception && Array.isArray(body.trace)) {
const trace = (body.trace as Array<{file: string; line: number}>)
.map((t, i) => `[${i + 1}] ${t.file}#L${t.line}`)
.join('<br>');
message = `${message}<br><details>${trace}</details>`;
}
throw new HTTPError(message || String(body), cloned);
} catch (error: any) {
emit('fetchError', error);
await showModal({
mode: 'alert',
title: t('general.fatalError'),
dangerousHTML: error.message,
type: 'danger',
okButtonType: 'outline-light',
});
return {code: -1, message: t('general.fatalError')};
}
return { code: -1, message: t('general.fatalError') }
}
}
export async function get<T = any>(url: string, parameters: Record<string, string> | URLSearchParams = empty): Promise<T> {
emit('beforeFetch', {
method: 'GET',
url,
data: parameters,
});
export function get<T = any>(url: string, params = empty): Promise<T> {
emit('beforeFetch', {
method: 'GET',
url,
data: params,
})
const qs = new URLSearchParams(parameters).toString();
const qs = new URLSearchParams(params).toString()
return walkFetch(new Request(`${blessing.base_url}${url}?${qs}`, init));
return walkFetch(new Request(`${blessing.base_url}${url}?${qs}`, init))
}
async function nonGet<T = any>(
method: string,
url: string,
data?: FormData | Record<string, unknown>,
function nonGet<T = any>(
method: string,
url: string,
data?: FormData | Record<string, unknown>,
): Promise<T> {
emit('beforeFetch', {
method: method.toUpperCase(),
url,
data,
});
emit('beforeFetch', {
method: method.toUpperCase(),
url,
data,
})
const request = new Request(`${blessing.base_url}${url}`, {
body: data instanceof FormData ? data : JSON.stringify(data),
method: method.toUpperCase(),
...init,
});
if (!(data instanceof FormData)) {
request.headers.set('Content-Type', 'application/json');
}
const request = new Request(`${blessing.base_url}${url}`, {
body: data instanceof FormData ? data : JSON.stringify(data),
method: method.toUpperCase(),
...init,
})
if (!(data instanceof FormData)) {
request.headers.set('Content-Type', 'application/json')
}
return walkFetch(request);
return walkFetch(request)
}
export async function post<T = any>(
url: string,
data?: FormData | Record<string, unknown>,
export function post<T = any>(
url: string,
data?: FormData | Record<string, unknown>,
): Promise<T> {
return nonGet<T>('POST', url, data);
return nonGet<T>('POST', url, data)
}
export async function put<T = any>(
url: string,
data?: FormData | Record<string, unknown>,
export function put<T = any>(
url: string,
data?: FormData | Record<string, unknown>,
): Promise<T> {
return nonGet<T>('PUT', url, data);
return nonGet<T>('PUT', url, data)
}
export async function del<T = any>(
url: string,
data?: FormData | Record<string, unknown>,
export function del<T = any>(
url: string,
data?: FormData | Record<string, unknown>,
): Promise<T> {
return nonGet<T>('DELETE', url, data);
return nonGet<T>('DELETE', url, data)
}
blessing.fetch = {
get,
post,
put,
del,
};
get,
post,
put,
del,
}

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