Compare commits
1289 Commits
0.0.47-nig
...
mai-unique
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
385bbd115d | ||
|
|
6a45df683b | ||
|
|
21e023e609 | ||
|
|
f6489d5ac0 | ||
|
|
256aac8faf | ||
|
|
a1be699ec5 | ||
|
|
d71af941b0 | ||
|
|
a1b56f6e0b | ||
|
|
d8022cc1a4 | ||
|
|
9ba7f5022e | ||
|
|
437ed2ee60 | ||
|
|
4d4335004f | ||
|
|
ce95f2165d | ||
|
|
931e611cf7 | ||
|
|
81ef029bf6 | ||
|
|
223de57b65 | ||
|
|
f1d1b81456 | ||
|
|
8aa829ab02 | ||
|
|
8fb443d41d | ||
|
|
edc62b3cfc | ||
|
|
644cdef95f | ||
|
|
dc54473669 | ||
|
|
332eacd2cc | ||
|
|
c26a670b05 | ||
|
|
1ccb8694d8 | ||
|
|
91db21067c | ||
|
|
834546e3ba | ||
|
|
6518fe6946 | ||
|
|
9b21193be2 | ||
|
|
99983b1eb1 | ||
|
|
712e2c9d02 | ||
|
|
f963e6aa03 | ||
|
|
d3c25e6b12 | ||
|
|
667abf2131 | ||
|
|
8a1e17ecd3 | ||
|
|
809004e16b | ||
|
|
e35df8a029 | ||
|
|
d9081563c2 | ||
|
|
29e757ba75 | ||
|
|
48724bae8b | ||
|
|
b1de430f0b | ||
|
|
6cd18ba7f7 | ||
|
|
07d167d961 | ||
|
|
dadedbe129 | ||
|
|
6d8948cdf1 | ||
|
|
27ca67b6f9 | ||
|
|
7e27bf0785 | ||
|
|
a4e8cbe9e1 | ||
|
|
3d58a15b10 | ||
|
|
5c80aec50b | ||
|
|
b52916e62c | ||
|
|
91ea0c9c8e | ||
|
|
2a80a10eec | ||
|
|
7b8fb02398 | ||
|
|
74aa319f41 | ||
|
|
0de4856247 | ||
|
|
da61b1a3e7 | ||
|
|
0632213c8b | ||
|
|
daa8de203b | ||
|
|
537558e3d5 | ||
|
|
8d23b262c7 | ||
|
|
04a178eda6 | ||
|
|
977f353f9c | ||
|
|
ae6ff97b62 | ||
|
|
fa45891af4 | ||
|
|
159b36607a | ||
|
|
a731687607 | ||
|
|
a77a74ba61 | ||
|
|
fc35381e1b | ||
|
|
6f837830ab | ||
|
|
8197361fb0 | ||
|
|
c7d12fbdf8 | ||
|
|
0cb3fd3134 | ||
|
|
77a791e5da | ||
|
|
42d94b43b1 | ||
|
|
cb6bf00236 | ||
|
|
4f6bd11a70 | ||
|
|
0f14326449 | ||
|
|
b421b4476b | ||
|
|
18554ec439 | ||
|
|
806e24b9f1 | ||
|
|
fa1d69f1f9 | ||
|
|
33aebc42b3 | ||
|
|
ffcb94674e | ||
|
|
c1323a6ba1 | ||
|
|
ea70da8fbf | ||
|
|
1bdb17f073 | ||
|
|
506031b5cb | ||
|
|
4a981900aa | ||
|
|
6fa052bfcf | ||
|
|
19ac32d328 | ||
|
|
44eab78935 | ||
|
|
a2413f3635 | ||
|
|
13b1d8fc34 | ||
|
|
8b90449970 | ||
|
|
038e76ed94 | ||
|
|
390c80c46c | ||
|
|
dffae008cd | ||
|
|
bcf9af71e2 | ||
|
|
5787d32c1a | ||
|
|
39b5032303 | ||
|
|
5f871b1945 | ||
|
|
346f1c991a | ||
|
|
b14a56bb6c | ||
|
|
f75d0acb1c | ||
|
|
b2d1fd916d | ||
|
|
b7f1e30708 | ||
|
|
88863d8d01 | ||
|
|
c275c54fca | ||
|
|
b97ace2c6e | ||
|
|
c78c4689f1 | ||
|
|
f39ccf7629 | ||
|
|
885dfb5bea | ||
|
|
8037273672 | ||
|
|
2e9c0656de | ||
|
|
e85d294d12 | ||
|
|
24bf6cffc3 | ||
|
|
16762d1a46 | ||
|
|
6a54005472 | ||
|
|
812d910212 | ||
|
|
b7c8fba464 | ||
|
|
89424f6466 | ||
|
|
0411505341 | ||
|
|
6938083463 | ||
|
|
054b286388 | ||
|
|
add1e02d2f | ||
|
|
fd44744029 | ||
|
|
6844e1b435 | ||
|
|
8140380673 | ||
|
|
66ad9e8856 | ||
|
|
da467ec8ee | ||
|
|
f0923c51e6 | ||
|
|
d1953e792a | ||
|
|
b3294eed68 | ||
|
|
37946c5aba | ||
|
|
ce80f65e9f | ||
|
|
4dce42b85f | ||
|
|
88702085bb | ||
|
|
2719522e07 | ||
|
|
dd573945ed | ||
|
|
9cffb19332 | ||
|
|
6631bb593c | ||
|
|
f5959925aa | ||
|
|
5b20cb316b | ||
|
|
8cb7ff8ed4 | ||
|
|
4bcf1f2d9e | ||
|
|
452b077822 | ||
|
|
f37a32ceab | ||
|
|
7182514a64 | ||
|
|
da60131051 | ||
|
|
aa9804d2df | ||
|
|
10c1b9bc29 | ||
|
|
698422a41e | ||
|
|
ac16f40303 | ||
|
|
e41bdecd5b | ||
|
|
c9a0a8d2b5 | ||
|
|
c308940c4b | ||
|
|
99770ccd2f | ||
|
|
2d4bb90acc | ||
|
|
e78d80b99d | ||
|
|
9f5cd6dc88 | ||
|
|
85c0b670da | ||
|
|
813ec7d294 | ||
|
|
d66eb239fa | ||
|
|
9fcc46b5d5 | ||
|
|
3ebf8a2061 | ||
|
|
de98085e84 | ||
|
|
2557b55817 | ||
|
|
128706e8a1 | ||
|
|
d854d8ae0b | ||
|
|
637191836a | ||
|
|
69ab9d96f7 | ||
|
|
073febe24a | ||
|
|
f01a4fcfac | ||
|
|
4f81a4e9b4 | ||
|
|
1a06033964 | ||
|
|
c5dad11e5e | ||
|
|
5ed89754b3 | ||
|
|
ee88be613c | ||
|
|
ebafb4c05e | ||
|
|
70466d0c94 | ||
|
|
340003c568 | ||
|
|
db5343fba3 | ||
|
|
8434842c65 | ||
|
|
2482881117 | ||
|
|
4afe2160e1 | ||
|
|
6225390b7f | ||
|
|
d5a9c98ff9 | ||
|
|
bed1b85319 | ||
|
|
8a728ad28a | ||
|
|
c42f17c96e | ||
|
|
054352356b | ||
|
|
2646f642b5 | ||
|
|
436bdde60a | ||
|
|
07210a23b7 | ||
|
|
da36ef4002 | ||
|
|
e3b06b110f | ||
|
|
792dce6843 | ||
|
|
0ec048ceba | ||
|
|
07631e9b02 | ||
|
|
4834363fb5 | ||
|
|
734dbfb761 | ||
|
|
37044dae01 | ||
|
|
e9ee31b22a | ||
|
|
cf0e3ce989 | ||
|
|
27664164fa | ||
|
|
b7360c426b | ||
|
|
0e176d5608 | ||
|
|
a947a81772 | ||
|
|
bbb4185fac | ||
|
|
e34f0587fe | ||
|
|
0e02dd660c | ||
|
|
2376e511ac | ||
|
|
56bf447cdb | ||
|
|
5ebb1718d6 | ||
|
|
9143b92932 | ||
|
|
fbff4a8cb1 | ||
|
|
b02371e4c3 | ||
|
|
e32a2bbe81 | ||
|
|
10ebd61519 | ||
|
|
7ac90891ca | ||
|
|
711c18a7f1 | ||
|
|
b3cb08316a | ||
|
|
42b8b9ce4a | ||
|
|
fc4834ebd6 | ||
|
|
24ab79a09a | ||
|
|
786a8832d3 | ||
|
|
4d25b6a43c | ||
|
|
29bb54d2cc | ||
|
|
cd075a3559 | ||
|
|
0455a83ef1 | ||
|
|
6c5791b1fe | ||
|
|
705b6cc03d | ||
|
|
b190e54285 | ||
|
|
478db15211 | ||
|
|
1542f3811d | ||
|
|
85dd8029af | ||
|
|
11beb6676e | ||
|
|
99d7fe5ca2 | ||
|
|
248c1ce189 | ||
|
|
bf972681d5 | ||
|
|
996632ac73 | ||
|
|
b28a1986c9 | ||
|
|
fb96e93184 | ||
|
|
ac4db91df4 | ||
|
|
408845878b | ||
|
|
7933d49bb2 | ||
|
|
6945032077 | ||
|
|
0af137ba8c | ||
|
|
de3d376063 | ||
|
|
36da872932 | ||
|
|
ff2ed50dea | ||
|
|
6bb2685e03 | ||
|
|
5eb0424ee7 | ||
|
|
80536ef4fb | ||
|
|
f3bebc6fa2 | ||
|
|
d0bb3cc75c | ||
|
|
98213cff67 | ||
|
|
c074de5876 | ||
|
|
906bdfa15e | ||
|
|
e844164cf6 | ||
|
|
1b47bfa2f1 | ||
|
|
33997c9a82 | ||
|
|
4713a44573 | ||
|
|
be7b0945e9 | ||
|
|
0f1bfc5a17 | ||
|
|
3bc9f1382c | ||
|
|
a08e93d975 | ||
|
|
91a120599f | ||
|
|
5d399b2497 | ||
|
|
0cab18b9b5 | ||
|
|
903da8732d | ||
|
|
6857ae5182 | ||
|
|
5bcbffcdf0 | ||
|
|
953083a0bf | ||
|
|
1810bbe2d5 | ||
|
|
f716ab0c1b | ||
|
|
e04e5596a3 | ||
|
|
f239d498ad | ||
|
|
7b768b5b5b | ||
|
|
60813274dc | ||
|
|
ad5bc4fc0d | ||
|
|
c1c6949175 | ||
|
|
26840700ee | ||
|
|
ec610de266 | ||
|
|
f4129ff5c2 | ||
|
|
b8cc6d9809 | ||
|
|
9384d1d96f | ||
|
|
854b6b76a0 | ||
|
|
bc836e973c | ||
|
|
bf9855abd1 | ||
|
|
4006438d93 | ||
|
|
cdfb86e021 | ||
|
|
81e0232712 | ||
|
|
e67b68aa20 | ||
|
|
a075de4711 | ||
|
|
6d782352f7 | ||
|
|
587993c957 | ||
|
|
aaca3e65ce | ||
|
|
ce53acdacf | ||
|
|
a449bac130 | ||
|
|
060bd32417 | ||
|
|
08a1595d3e | ||
|
|
b88c56b67a | ||
|
|
7deb395fd9 | ||
|
|
2ef104224b | ||
|
|
d84b2f3870 | ||
|
|
7e5467935b | ||
|
|
f7c1714cb8 | ||
|
|
12724cea56 | ||
|
|
2cea66cba5 | ||
|
|
ac01469eac | ||
|
|
cb4cc4e7d9 | ||
|
|
c648493a9e | ||
|
|
a36da6ebde | ||
|
|
4fd2fc7e00 | ||
|
|
39646732b6 | ||
|
|
8791507aca | ||
|
|
c173b2a230 | ||
|
|
05d2df623e | ||
|
|
e75a1fcd12 | ||
|
|
529165f2b5 | ||
|
|
9a5743a27e | ||
|
|
271ef9bf00 | ||
|
|
3a97f7645e | ||
|
|
a84bf9efef | ||
|
|
daa5129f65 | ||
|
|
ac375abf5e | ||
|
|
bae5a7c838 | ||
|
|
1bcb7210c6 | ||
|
|
c15dcf6b98 | ||
|
|
9ead7a413e | ||
|
|
07817b04fb | ||
|
|
e65d67f12e | ||
|
|
e39f013808 | ||
|
|
c34affc215 | ||
|
|
07b8cc04be | ||
|
|
78a396ce4b | ||
|
|
43997f2215 | ||
|
|
e9bac0a737 | ||
|
|
e7c69d2a6b | ||
|
|
8c3400ee41 | ||
|
|
3d79c939e9 | ||
|
|
27b8e6bd21 | ||
|
|
24ecaab570 | ||
|
|
74e39c437d | ||
|
|
c1c7788cd3 | ||
|
|
a2db465825 | ||
|
|
9605264b9a | ||
|
|
24e6808984 | ||
|
|
0d9c7a4cc2 | ||
|
|
3c6ecf1563 | ||
|
|
5c634d6ff9 | ||
|
|
6b51155bac | ||
|
|
1873ad8355 | ||
|
|
8087396188 | ||
|
|
5128db9f6c | ||
|
|
ef832461c0 | ||
|
|
85493cdfd8 | ||
|
|
e557f1361d | ||
|
|
9598ac5a50 | ||
|
|
5ee7add355 | ||
|
|
81c1e6e887 | ||
|
|
b7004b3866 | ||
|
|
5341326811 | ||
|
|
776c08e605 | ||
|
|
b7c5d18df1 | ||
|
|
39dc6c576a | ||
|
|
d9fc262003 | ||
|
|
9b7f2b3a79 | ||
|
|
1ad4ac2d63 | ||
|
|
9ca7949bf0 | ||
|
|
d2174364b2 | ||
|
|
91238c3a9c | ||
|
|
d32c8c999b | ||
|
|
6580b78485 | ||
|
|
0eec8dea05 | ||
|
|
8fa356242e | ||
|
|
a13611f601 | ||
|
|
e8307cdcd9 | ||
|
|
ca425cf949 | ||
|
|
9f57d393bf | ||
|
|
84c59e2c8b | ||
|
|
212f60db60 | ||
|
|
fbbdb056d7 | ||
|
|
e3c0fe5e78 | ||
|
|
a3afb1a2b8 | ||
|
|
ac94b6d917 | ||
|
|
8db9580ff5 | ||
|
|
489c00ebb0 | ||
|
|
d58fe84439 | ||
|
|
ffe3843747 | ||
|
|
b370af3c19 | ||
|
|
cdd3c81bdc | ||
|
|
eb72839e2b | ||
|
|
6457cedd9b | ||
|
|
be72ea0c98 | ||
|
|
bf5691bdb6 | ||
|
|
a6a8734599 | ||
|
|
d0aecc76ed | ||
|
|
3b80b8d7f1 | ||
|
|
c11bb3be59 | ||
|
|
7ee4c14fae | ||
|
|
17a0209c8c | ||
|
|
fc10c05731 | ||
|
|
9ef0d0edfb | ||
|
|
473f4a4295 | ||
|
|
94ba1f0b09 | ||
|
|
8903fa268a | ||
|
|
6ad980d471 | ||
|
|
9a6e9c4660 | ||
|
|
f7c842774b | ||
|
|
fde952fcd9 | ||
|
|
a71c2bd8ec | ||
|
|
7c4f887ef4 | ||
|
|
b32b0e970c | ||
|
|
836f789fc9 | ||
|
|
6b71e2f22a | ||
|
|
1fa83d3f8f | ||
|
|
247f8f132b | ||
|
|
3fcdf38d4a | ||
|
|
0626d1c466 | ||
|
|
f0da7c6300 | ||
|
|
2554478a38 | ||
|
|
d7f24759d8 | ||
|
|
df3bd6fbec | ||
|
|
11ab81a484 | ||
|
|
91e7a092c4 | ||
|
|
bca5130020 | ||
|
|
153029abdd | ||
|
|
03ed3f13f4 | ||
|
|
8c7fd78bd4 | ||
|
|
a813535e3f | ||
|
|
7cae5f8f10 | ||
|
|
070c19d784 | ||
|
|
0833cd8a9b | ||
|
|
64f3a2db58 | ||
|
|
ad8a425d30 | ||
|
|
40d5c8d79f | ||
|
|
c6e471323f | ||
|
|
60a0c8726e | ||
|
|
5772ff78e6 | ||
|
|
222ed29b6c | ||
|
|
23870523fb | ||
|
|
b1a1d36b66 | ||
|
|
2946c51774 | ||
|
|
8b72214780 | ||
|
|
0f701ad2d3 | ||
|
|
d686c48a0b | ||
|
|
80555f9c96 | ||
|
|
ec1155b1ba | ||
|
|
7377386ee2 | ||
|
|
f4bb1101bf | ||
|
|
e44188b830 | ||
|
|
c5d81afdf6 | ||
|
|
f6d55fec35 | ||
|
|
2ef8219f15 | ||
|
|
bf9197b3e4 | ||
|
|
27b1a31436 | ||
|
|
9b51c8cab4 | ||
|
|
114a452609 | ||
|
|
ef85156bae | ||
|
|
e55d17fd08 | ||
|
|
8b83205b0a | ||
|
|
2251350a4e | ||
|
|
f106a31990 | ||
|
|
c9f222583a | ||
|
|
13fc51a8a5 | ||
|
|
21309cddf0 | ||
|
|
86164ba518 | ||
|
|
8173003144 | ||
|
|
f9c8b00587 | ||
|
|
b77da0f143 | ||
|
|
ed955150df | ||
|
|
f282197611 | ||
|
|
93ce932d28 | ||
|
|
29505fa4a3 | ||
|
|
991442d5c0 | ||
|
|
e7b5991dbf | ||
|
|
fb72317c6f | ||
|
|
7992568c0f | ||
|
|
38666b7c99 | ||
|
|
1f3f143ffb | ||
|
|
156ece4bb5 | ||
|
|
770d1ae689 | ||
|
|
d7287c48cf | ||
|
|
b4c329f2f9 | ||
|
|
cb2219e2cd | ||
|
|
65f0bfa8a4 | ||
|
|
7090e0a47b | ||
|
|
fbb4d61194 | ||
|
|
634b0b50ff | ||
|
|
7ff66e9277 | ||
|
|
b93cc3ab20 | ||
|
|
55e7052189 | ||
|
|
5a9808de59 | ||
|
|
a30b34df70 | ||
|
|
482b19dd5a | ||
|
|
7895ed89f1 | ||
|
|
8449853076 | ||
|
|
affec8d3c1 | ||
|
|
362b69d921 | ||
|
|
9d463c7b4a | ||
|
|
cd7da64794 | ||
|
|
5c95f2971f | ||
|
|
ab3f3f0633 | ||
|
|
ba61ac46d1 | ||
|
|
48205d8a6c | ||
|
|
b4cbb1fd14 | ||
|
|
c8db3ec762 | ||
|
|
f6cf157930 | ||
|
|
4a84a9ed8e | ||
|
|
dbb41ba249 | ||
|
|
d3adec5a23 | ||
|
|
1fd030f909 | ||
|
|
b6dfeb475d | ||
|
|
39050c6de6 | ||
|
|
c36926c915 | ||
|
|
3bf3241bd7 | ||
|
|
df863e879f | ||
|
|
b13af00061 | ||
|
|
1c2215a8a2 | ||
|
|
555ae35bb9 | ||
|
|
8337a1698e | ||
|
|
f0bfa96937 | ||
|
|
7b143dd38f | ||
|
|
5201c5933c | ||
|
|
4c1d501856 | ||
|
|
e001533f33 | ||
|
|
c854dd9a45 | ||
|
|
4215b39539 | ||
|
|
8e882aafa1 | ||
|
|
25edbf06c7 | ||
|
|
4f05365da3 | ||
|
|
bf7de99524 | ||
|
|
08c27b6c58 | ||
|
|
60661757c6 | ||
|
|
5ba64483fb | ||
|
|
a30c9391eb | ||
|
|
7023e726bd | ||
|
|
c616ea81c6 | ||
|
|
65f8b587af | ||
|
|
14f6b9c759 | ||
|
|
c83e0f8cff | ||
|
|
2bf86423c9 | ||
|
|
8dc3035b66 | ||
|
|
0aff0330e7 | ||
|
|
41852f2467 | ||
|
|
442ec76828 | ||
|
|
d8fc14e71b | ||
|
|
2630d32764 | ||
|
|
74d7eff577 | ||
|
|
355c9e2a3d | ||
|
|
501bf06ada | ||
|
|
4574bc0b2f | ||
|
|
c6c91b84fe | ||
|
|
066b33e3e8 | ||
|
|
15002c45d6 | ||
|
|
b41f3b9370 | ||
|
|
02e2700e96 | ||
|
|
6441dfd219 | ||
|
|
1e229c12cc | ||
|
|
4219f2db5b | ||
|
|
36ce636093 | ||
|
|
47f09f81ff | ||
|
|
bfa6df904d | ||
|
|
99d4f55c50 | ||
|
|
7728b4b1ab | ||
|
|
6a475434ad | ||
|
|
876a0bd108 | ||
|
|
ba13bfd9ad | ||
|
|
44bab8c0c7 | ||
|
|
2d229b82c3 | ||
|
|
c6cce7aa9a | ||
|
|
5cbf09f24e | ||
|
|
3ca7d3d615 | ||
|
|
25840be694 | ||
|
|
34ab608425 | ||
|
|
b498160b3a | ||
|
|
23aae3b5b9 | ||
|
|
97fdd096a8 | ||
|
|
0d21a02da9 | ||
|
|
ab94250b05 | ||
|
|
42ca6f79dc | ||
|
|
646795b753 | ||
|
|
de649915e2 | ||
|
|
0093f5a0de | ||
|
|
686b50eeda | ||
|
|
0c93b85024 | ||
|
|
49d4e88022 | ||
|
|
3a8616e225 | ||
|
|
d4178c85a9 | ||
|
|
de46790bdf | ||
|
|
c27070ae28 | ||
|
|
bb4c9477da | ||
|
|
95e78e4f93 | ||
|
|
d3d7b5a5c7 | ||
|
|
45a3d74284 | ||
|
|
cd972b5c61 | ||
|
|
341be8bdc1 | ||
|
|
101c24edc5 | ||
|
|
be34915cdf | ||
|
|
70aed1d5db | ||
|
|
d8c1144881 | ||
|
|
68ec7f504a | ||
|
|
3ab2b16042 | ||
|
|
d7fc6f9f49 | ||
|
|
26a72244c0 | ||
|
|
abc21badb1 | ||
|
|
aa1caacfd6 | ||
|
|
3663eb63e7 | ||
|
|
e885700680 | ||
|
|
d6170d602a | ||
|
|
4dbb287e11 | ||
|
|
e537e0f115 | ||
|
|
3613d7a37b | ||
|
|
b5e98f505f | ||
|
|
3dc9ca6822 | ||
|
|
e13ddeaaad | ||
|
|
56ce7f9696 | ||
|
|
4ebddf78ed | ||
|
|
2682165da8 | ||
|
|
373e7dc8ad | ||
|
|
0551f8bff1 | ||
|
|
b4454cc812 | ||
|
|
40fb1c8868 | ||
|
|
f97cb4a1bb | ||
|
|
56d0786702 | ||
|
|
d880ecd709 | ||
|
|
1bee9e19e6 | ||
|
|
c5879ae5a7 | ||
|
|
64f458e15a | ||
|
|
2fa5d09fc9 | ||
|
|
d6fc60e02b | ||
|
|
bb9bfd6396 | ||
|
|
0fbe139e8d | ||
|
|
571591f021 | ||
|
|
8a1d2383b8 | ||
|
|
00c5edcea7 | ||
|
|
39d62099df | ||
|
|
c5d6f6f5b9 | ||
|
|
13f3cf1e90 | ||
|
|
93f6bf8ba3 | ||
|
|
1cdbed51cd | ||
|
|
50ae04bb4e | ||
|
|
a55d503faa | ||
|
|
7fc4f83eb5 | ||
|
|
bc831b4d30 | ||
|
|
c6190146aa | ||
|
|
3f01152a4a | ||
|
|
ad5c652a8f | ||
|
|
9609db941b | ||
|
|
bbb8447f5c | ||
|
|
22ca06af3e | ||
|
|
af11758190 | ||
|
|
32fcc25ea4 | ||
|
|
b3fcf8dd5e | ||
|
|
b7d2a97f05 | ||
|
|
ad13875137 | ||
|
|
e14a131480 | ||
|
|
64ba0db228 | ||
|
|
c99d8e7e75 | ||
|
|
305d1cea94 | ||
|
|
f314b3982e | ||
|
|
5ea2615b93 | ||
|
|
17123fec35 | ||
|
|
73d05e7cbf | ||
|
|
3380ea3609 | ||
|
|
101527d3e1 | ||
|
|
df9ab3250c | ||
|
|
d533df52de | ||
|
|
d2cf16d046 | ||
|
|
40a65b5e13 | ||
|
|
fa33cb680e | ||
|
|
2757eb91ce | ||
|
|
2842429ced | ||
|
|
fb2a26c5b7 | ||
|
|
cab1dc8838 | ||
|
|
0ec76dcde3 | ||
|
|
c41046953e | ||
|
|
30f740a430 | ||
|
|
1e8c0ce99b | ||
|
|
aa3a3d9181 | ||
|
|
6d0f528201 | ||
|
|
131cd5915c | ||
|
|
f5512fa162 | ||
|
|
484bb758ae | ||
|
|
89461893a4 | ||
|
|
54e865feb2 | ||
|
|
015fa3dc9f | ||
|
|
cf015be49f | ||
|
|
b6c8993f7e | ||
|
|
1ef37d91e8 | ||
|
|
7fc81cf363 | ||
|
|
123bf9de34 | ||
|
|
d3f6b75d34 | ||
|
|
a5fe5f53e2 | ||
|
|
e91029f66e | ||
|
|
2a7ce54c28 | ||
|
|
f3b2d4dc57 | ||
|
|
95b9871f7f | ||
|
|
533af83749 | ||
|
|
e4330fee92 | ||
|
|
5fec57e8e3 | ||
|
|
95a06d572b | ||
|
|
cc8c125934 | ||
|
|
91c605ee4b | ||
|
|
f44fe4def1 | ||
|
|
7c0a1ea089 | ||
|
|
ce5c4d1111 | ||
|
|
98952972a0 | ||
|
|
f728b6ab1b | ||
|
|
e799b48877 | ||
|
|
fc8ecb7470 | ||
|
|
ac18234e29 | ||
|
|
59b17aa47e | ||
|
|
9155bfb886 | ||
|
|
cbe683d25e | ||
|
|
64f057a415 | ||
|
|
3da308346e | ||
|
|
313dd681de | ||
|
|
aaf7e1e3e5 | ||
|
|
9f831fd8b5 | ||
|
|
450397481e | ||
|
|
6fb8978f48 | ||
|
|
4a7bf4b31e | ||
|
|
38e94210e4 | ||
|
|
d338809750 | ||
|
|
7fd7e17d1d | ||
|
|
a5a5bd80c4 | ||
|
|
d264ca1ed4 | ||
|
|
85dd19509c | ||
|
|
faf1945933 | ||
|
|
3c6d6ff702 | ||
|
|
c6ecc89ad3 | ||
|
|
abed79441d | ||
|
|
906199a517 | ||
|
|
6f34c21d94 | ||
|
|
9ba1a68b51 | ||
|
|
073c72fd63 | ||
|
|
3ac4af1558 | ||
|
|
5057f6848f | ||
|
|
b3955731c2 | ||
|
|
af83cf552e | ||
|
|
a0426044e8 | ||
|
|
432635d567 | ||
|
|
02b78320ec | ||
|
|
f1461f905d | ||
|
|
e1cdb3ab65 | ||
|
|
6218424be3 | ||
|
|
5a9b7e296f | ||
|
|
f4cc9c7734 | ||
|
|
e0c7998448 | ||
|
|
752d65557f | ||
|
|
a952674df7 | ||
|
|
25f5f6e1f7 | ||
|
|
0f1d6c0984 | ||
|
|
8dd4bb9d61 | ||
|
|
98275ade59 | ||
|
|
95cc9f0e21 | ||
|
|
742ea50c2c | ||
|
|
54b1174e1b | ||
|
|
e07de72fa4 | ||
|
|
13b4af3734 | ||
|
|
29566a6c93 | ||
|
|
7669f7d9a0 | ||
|
|
5913d5b585 | ||
|
|
d9a332de44 | ||
|
|
e85533686e | ||
|
|
0100140dc0 | ||
|
|
8def9e8931 | ||
|
|
6fc2f26983 | ||
|
|
ed1ed6cbe9 | ||
|
|
10d19a5392 | ||
|
|
7bbd90ab91 | ||
|
|
9565d48b04 | ||
|
|
284d366b44 | ||
|
|
a9893379f4 | ||
|
|
50677ad81d | ||
|
|
71d7fcbe65 | ||
|
|
8342acbd49 | ||
|
|
d5296763ad | ||
|
|
73efa4fe91 | ||
|
|
82f573e1a1 | ||
|
|
4ef0ac3fee | ||
|
|
bc246f39d2 | ||
|
|
f9af23dbca | ||
|
|
68f8ef0b24 | ||
|
|
16f6acf8fc | ||
|
|
3faa5b2f52 | ||
|
|
04a7c068f4 | ||
|
|
92dee27634 | ||
|
|
7dda25f96b | ||
|
|
40f700910a | ||
|
|
aa90b34511 | ||
|
|
45cf082bb9 | ||
|
|
0ab78983d4 | ||
|
|
e137210cbc | ||
|
|
3093755c9e | ||
|
|
94c4950d23 | ||
|
|
fa0a624b7c | ||
|
|
f3fabe1708 | ||
|
|
52ec890e2c | ||
|
|
2a10471e0b | ||
|
|
94c1974d2f | ||
|
|
f0a8014efb | ||
|
|
96cac6ca68 | ||
|
|
0da50bc693 | ||
|
|
1169ac44b4 | ||
|
|
38367279ff | ||
|
|
ef00cfbddd | ||
|
|
e514e4b64e | ||
|
|
f1af07e921 | ||
|
|
44cf022e70 | ||
|
|
7e68de5a17 | ||
|
|
51f73d77bf | ||
|
|
fa4ccf07b8 | ||
|
|
49da7aafd0 | ||
|
|
58ca71baaa | ||
|
|
2c550a0874 | ||
|
|
dcb671acd8 | ||
|
|
56600d3f27 | ||
|
|
6913f7bdf5 | ||
|
|
bcc2d286ed | ||
|
|
aed6c2123f | ||
|
|
68626fecd7 | ||
|
|
441d7376cb | ||
|
|
c9ac38de01 | ||
|
|
b9c063c41e | ||
|
|
55804be70e | ||
|
|
2b749af917 | ||
|
|
9378dfdd04 | ||
|
|
46768c77b7 | ||
|
|
ff9358b986 | ||
|
|
f3090870be | ||
|
|
666fbe8ce7 | ||
|
|
67b29851ea | ||
|
|
1a2cd201a7 | ||
|
|
5041bf67a5 | ||
|
|
1a2f3bf80e | ||
|
|
d7a231eb18 | ||
|
|
21c9c190aa | ||
|
|
a781c2d665 | ||
|
|
09c3ce3164 | ||
|
|
a7888a63fa | ||
|
|
79dd56d017 | ||
|
|
882d04f50c | ||
|
|
a7fd414ce6 | ||
|
|
eb30451cfa | ||
|
|
a48f2b1f17 | ||
|
|
d3665d64a6 | ||
|
|
a4bbc9c3c6 | ||
|
|
b0ffda42bc | ||
|
|
b333045d41 | ||
|
|
ef5d0a81eb | ||
|
|
48819c10a9 | ||
|
|
9ae23e4395 | ||
|
|
4d36efebb7 | ||
|
|
e842a37654 | ||
|
|
c821626dc1 | ||
|
|
16aba9ff96 | ||
|
|
a11bfdb13b | ||
|
|
c0437e55eb | ||
|
|
279bcbfeab | ||
|
|
6555263496 | ||
|
|
8db4e17a8a | ||
|
|
4a5bd3135f | ||
|
|
32eb98361a | ||
|
|
2ba5073d55 | ||
|
|
9ea5e2cd90 | ||
|
|
284a1f0b57 | ||
|
|
7b97f3d535 | ||
|
|
fb431fcc7b | ||
|
|
b16100e627 | ||
|
|
fde6b5df9b | ||
|
|
dc1ac106c0 | ||
|
|
0d4a26c05e | ||
|
|
ffe7a9294b | ||
|
|
15004b6ba2 | ||
|
|
dfd8d1b0c9 | ||
|
|
c2fef3fa25 | ||
|
|
d33c892303 | ||
|
|
e4ce97cf5d | ||
|
|
f331916bd5 | ||
|
|
5d3194dd41 | ||
|
|
f2574b516e | ||
|
|
9ee3e973c1 | ||
|
|
eb9e797017 | ||
|
|
d0c305b3eb | ||
|
|
db2a7208da | ||
|
|
e8958f5e53 | ||
|
|
8acee1251f | ||
|
|
acf117e43b | ||
|
|
ed1b7f477b | ||
|
|
63cf1f5fa1 | ||
|
|
fa9b738cba | ||
|
|
3efbefe4c5 | ||
|
|
dcb797db38 | ||
|
|
8f9f9e9e82 | ||
|
|
642754a46b | ||
|
|
a5578335d3 | ||
|
|
c4309aa14c | ||
|
|
af3eb10034 | ||
|
|
88b7804123 | ||
|
|
4a383521d7 | ||
|
|
279b65cfa0 | ||
|
|
6ce644ea18 | ||
|
|
9ef5e8d037 | ||
|
|
a9e14a93dd | ||
|
|
8e2c0d8653 | ||
|
|
39a19fd9e6 | ||
|
|
68e1a0489f | ||
|
|
ece64c3f4a | ||
|
|
37f67469a6 | ||
|
|
a1b546152b | ||
|
|
3ae1f6c556 | ||
|
|
14757e2a35 | ||
|
|
d20a762dd8 | ||
|
|
5ff79f5ee1 | ||
|
|
afe28733db | ||
|
|
5e5fe6013d | ||
|
|
3f0196c8f8 | ||
|
|
4e38cf9d40 | ||
|
|
6026f6aebd | ||
|
|
856bcf1647 | ||
|
|
8e7196181c | ||
|
|
beb6697507 | ||
|
|
fd482d32a7 | ||
|
|
10169b03ce | ||
|
|
5f4a7cd7c9 | ||
|
|
59b52b8a47 | ||
|
|
02bffab38f | ||
|
|
256f08396f | ||
|
|
988a280111 | ||
|
|
7a44a457d5 | ||
|
|
2fa153e569 | ||
|
|
b589c78cfc | ||
|
|
293acbcc03 | ||
|
|
be0a841926 | ||
|
|
763cbfa656 | ||
|
|
d149b02c06 | ||
|
|
b83773dfa6 | ||
|
|
c992701387 | ||
|
|
bf43944c27 | ||
|
|
6dbed875e1 | ||
|
|
5166387f34 | ||
|
|
b44121597f | ||
|
|
f086b8abe9 | ||
|
|
795da9557b | ||
|
|
76249cb8f7 | ||
|
|
f4c4162e4b | ||
|
|
902cc9009e | ||
|
|
d93c2ee267 | ||
|
|
e5b864f07e | ||
|
|
8df4cd3dd6 | ||
|
|
2fdb6f15cb | ||
|
|
c7e493d7f5 | ||
|
|
043537a7b4 | ||
|
|
e7643f3894 | ||
|
|
bb2c8ae8e5 | ||
|
|
1c8860c596 | ||
|
|
66e65fcd14 | ||
|
|
7cef8f24db | ||
|
|
a82f3a7b07 | ||
|
|
4f41068c99 | ||
|
|
50dfb95c48 | ||
|
|
0b29ac00a7 | ||
|
|
759519d374 | ||
|
|
3d713b13da | ||
|
|
fcbe52539a | ||
|
|
bcd64286cd | ||
|
|
b89147120c | ||
|
|
c9ffd3cd11 | ||
|
|
cd62f31c17 | ||
|
|
98d63b880b | ||
|
|
04e11b0fea | ||
|
|
a873b28d9b | ||
|
|
b1b2ff6b8c | ||
|
|
cb96b5fa8f | ||
|
|
eb960209bf | ||
|
|
51a0e46f8c | ||
|
|
1251205fdd | ||
|
|
9a05629144 | ||
|
|
e0c71006d5 | ||
|
|
3d716a516a | ||
|
|
096648b2d7 | ||
|
|
02e57707de | ||
|
|
20468e612d | ||
|
|
af3aa497d1 | ||
|
|
bb53d1448b | ||
|
|
eccdd73908 | ||
|
|
1d4e1a8be2 | ||
|
|
8dc0f299a9 | ||
|
|
db1ffd5091 | ||
|
|
64a27e5708 | ||
|
|
214a356135 | ||
|
|
84f7953f21 | ||
|
|
a9a947203d | ||
|
|
8f250e755e | ||
|
|
30a7fa7ead | ||
|
|
4324d655d2 | ||
|
|
da1be9226a | ||
|
|
5597bf5d1e | ||
|
|
50029fbb24 | ||
|
|
f5c2dc747d | ||
|
|
dd55e336e4 | ||
|
|
a001a45cc4 | ||
|
|
49320ff623 | ||
|
|
8e898c50b4 | ||
|
|
5fa93e2a2a | ||
|
|
178cca1611 | ||
|
|
9e543e2c5a | ||
|
|
6a16e5534d | ||
|
|
2d1cad870b | ||
|
|
3fdf255ca5 | ||
|
|
ec9225dbf2 | ||
|
|
b469fe92dd | ||
|
|
dbc54b016c | ||
|
|
2cbad36f80 | ||
|
|
8b21f33eb6 | ||
|
|
1e8ff7dbc0 | ||
|
|
0937915839 | ||
|
|
a128546954 | ||
|
|
806953d107 | ||
|
|
afa39b29ed | ||
|
|
4c899555dd | ||
|
|
3e8395b0c6 | ||
|
|
a620f02d57 | ||
|
|
79a078fb70 | ||
|
|
cac2f49b06 | ||
|
|
06993b9d66 | ||
|
|
fce5ca592a | ||
|
|
e7058cf3c8 | ||
|
|
a2eeac786e | ||
|
|
ff7873313b | ||
|
|
f3b06ac0a6 | ||
|
|
00e57fc17d | ||
|
|
c8cc59aaca | ||
|
|
54057922f6 | ||
|
|
c175173821 | ||
|
|
52e9285551 | ||
|
|
823eea1f0a | ||
|
|
ae03a700de | ||
|
|
a089eade6e | ||
|
|
f8fb3d8a70 | ||
|
|
00a75f154e | ||
|
|
8d2313d799 | ||
|
|
0b8384fc3b | ||
|
|
73ab9efdb4 | ||
|
|
705f69510b | ||
|
|
f7e0a33935 | ||
|
|
729015d719 | ||
|
|
d83127a265 | ||
|
|
6e8f7ae698 | ||
|
|
17ee24286c | ||
|
|
133140bf71 | ||
|
|
5fafbf9ee8 | ||
|
|
6085da15a4 | ||
|
|
b93caf1839 | ||
|
|
c9787a521b | ||
|
|
26cabef74c | ||
|
|
8d2474768b | ||
|
|
a87146a401 | ||
|
|
bbf5ee5395 | ||
|
|
103ae607be | ||
|
|
6f63998000 | ||
|
|
a94952babc | ||
|
|
4b8385419e | ||
|
|
878a543818 | ||
|
|
e7337777cd | ||
|
|
fa1ed52c32 | ||
|
|
eda3fccb51 | ||
|
|
ec55fae1ec | ||
|
|
c88a98e355 | ||
|
|
0567e0f251 | ||
|
|
befa7d0e8e | ||
|
|
91913da205 | ||
|
|
cd8677a26d | ||
|
|
adf091e300 | ||
|
|
aa3b831a68 | ||
|
|
ab075c0554 | ||
|
|
4202012bbd | ||
|
|
739854935d | ||
|
|
3d88e734df | ||
|
|
a716a69b8b | ||
|
|
7ac7aacb6c | ||
|
|
7368001e3b | ||
|
|
574e0b4074 | ||
|
|
5d258eb8e1 | ||
|
|
5715fa97f7 | ||
|
|
7fe869b98b | ||
|
|
37aaa30387 | ||
|
|
c3b2d7653f | ||
|
|
cb22161156 | ||
|
|
6ad06c2d75 | ||
|
|
b291dd0ad7 | ||
|
|
3039a32f29 | ||
|
|
a8f5380070 | ||
|
|
e37867b9db | ||
|
|
110d6c81ee | ||
|
|
3da44ce604 | ||
|
|
9770c15188 | ||
|
|
cc568d9569 | ||
|
|
500a4b0b7e | ||
|
|
55cfb7b358 | ||
|
|
77b2f90259 | ||
|
|
e962baaf48 | ||
|
|
2cb5b18975 | ||
|
|
32084eb1e7 | ||
|
|
2815d76b1d | ||
|
|
9c4f146778 | ||
|
|
7b89016359 | ||
|
|
c7a4902af0 | ||
|
|
6f9b686317 | ||
|
|
16550e7a83 | ||
|
|
a9aa47e390 | ||
|
|
0846fb94db | ||
|
|
4c3aafd266 | ||
|
|
1e606f8b85 | ||
|
|
58596377b1 | ||
|
|
94a3234874 | ||
|
|
7e9db5b52d | ||
|
|
dc098d1ec7 | ||
|
|
195a8b4315 | ||
|
|
7df80bc56a | ||
|
|
1c541a4adf | ||
|
|
f29f563e50 | ||
|
|
f4280c0768 | ||
|
|
295ae14658 | ||
|
|
ccc2bcffce | ||
|
|
3a94ef57e3 | ||
|
|
db8d8db280 | ||
|
|
fdcef95d07 | ||
|
|
7b1d9a777d | ||
|
|
3cd8764dbf | ||
|
|
32826440cb | ||
|
|
a65fa8cf10 | ||
|
|
0ae29b1920 | ||
|
|
5748a11788 | ||
|
|
7d3579af4f | ||
|
|
e0dc3bd1f4 | ||
|
|
6200c56144 | ||
|
|
0b4a0eeb55 | ||
|
|
467f5bd2eb | ||
|
|
322d90adfa | ||
|
|
11eb7c058f | ||
|
|
361b251952 | ||
|
|
3d503971ae | ||
|
|
9faabba361 | ||
|
|
f33629aba1 | ||
|
|
a47ed71799 | ||
|
|
4a9b9d57e4 | ||
|
|
98c3f0ce5b | ||
|
|
78a3082bcb | ||
|
|
b64af43a7e | ||
|
|
1bcacbfebe | ||
|
|
f32db6c83b | ||
|
|
437b638973 | ||
|
|
670918efd3 | ||
|
|
006a49cfdb | ||
|
|
9794ee259a | ||
|
|
643e0e0c1f | ||
|
|
6afcb364d1 | ||
|
|
6d4a38404c | ||
|
|
b925c2ef20 | ||
|
|
e3f931d4f5 | ||
|
|
01b5d63972 | ||
|
|
97794ce7c5 | ||
|
|
ac4c66a1f7 | ||
|
|
9750e26d4b | ||
|
|
4b14412190 | ||
|
|
207c2e10e3 | ||
|
|
7c73515427 | ||
|
|
aea7108940 | ||
|
|
2bdd97d889 | ||
|
|
93b6dd3374 | ||
|
|
bf3c123658 | ||
|
|
06c0a361fd | ||
|
|
92510845d6 | ||
|
|
8ab57859f6 | ||
|
|
0608b50193 | ||
|
|
e14ff26915 | ||
|
|
8932a16468 | ||
|
|
3804db142f | ||
|
|
7e198bd7a1 | ||
|
|
85301c92ec | ||
|
|
2ca6be77ed | ||
|
|
48558bec0f | ||
|
|
31af8669b5 | ||
|
|
d716ee5d26 | ||
|
|
8e1c07d530 | ||
|
|
0d7b52aadc | ||
|
|
25eb99f014 | ||
|
|
ab8276df2f | ||
|
|
68569ad875 | ||
|
|
c0e77d9eec | ||
|
|
27297c5d24 | ||
|
|
b6a7a02b23 | ||
|
|
534e20a072 | ||
|
|
9478da81a9 | ||
|
|
ee958f20d2 | ||
|
|
00edf44828 | ||
|
|
f3a49533fd | ||
|
|
3b6517090c | ||
|
|
5b2687ae83 | ||
|
|
c3402e8d44 | ||
|
|
a9625dfecd | ||
|
|
d6c8464e97 | ||
|
|
1b557d5f8c | ||
|
|
e1cf944db7 | ||
|
|
cb873efd38 | ||
|
|
ee5acfa35f | ||
|
|
2904c55f84 | ||
|
|
4667e9652f | ||
|
|
905b4fe92e | ||
|
|
85ccc78f8f | ||
|
|
ad3bb89dc9 | ||
|
|
106bded9b6 | ||
|
|
f46c8a03d9 | ||
|
|
5d9693c419 | ||
|
|
126546a938 | ||
|
|
bb3902730b | ||
|
|
1b9e25e81c | ||
|
|
b11439ca87 | ||
|
|
66034ea407 | ||
|
|
6690c665dd | ||
|
|
4b71cd9940 | ||
|
|
8b5ef24681 | ||
|
|
e161890eaa | ||
|
|
348f27237b | ||
|
|
c19164269a | ||
|
|
071491b459 | ||
|
|
e1180a9a14 | ||
|
|
f76a027b32 | ||
|
|
cc1a91e5cd | ||
|
|
fcee4d13da | ||
|
|
fa567ce0e2 | ||
|
|
c10085b65a | ||
|
|
a8465408cf | ||
|
|
d123a50054 | ||
|
|
bd09e4017a | ||
|
|
8b5e29d29e | ||
|
|
6e427b060a | ||
|
|
7d0f70f1c0 | ||
|
|
5f1ca64d65 | ||
|
|
fb58f08e44 | ||
|
|
017d00371d | ||
|
|
abe1d3ad29 | ||
|
|
fc48ba1994 | ||
|
|
279fe5dcb8 | ||
|
|
ccb3f7ef34 | ||
|
|
de12ec6548 | ||
|
|
14bd2480ce | ||
|
|
8152b9ab0d | ||
|
|
e6e4782d51 | ||
|
|
aa5f4fb986 | ||
|
|
aa4fe50eeb | ||
|
|
df072f1c40 | ||
|
|
9b3e202eb8 | ||
|
|
b403189afb | ||
|
|
82d076b87d | ||
|
|
dcdf951ebc | ||
|
|
4c1f8e4005 | ||
|
|
672ecc7f0a | ||
|
|
81f05528d8 | ||
|
|
57214aadfc | ||
|
|
6209a0120b | ||
|
|
98dcb3fbcb | ||
|
|
2211e1c816 | ||
|
|
36bf37da8d | ||
|
|
21be74fb05 | ||
|
|
870ca6cd7f | ||
|
|
4e832a5eb2 | ||
|
|
7b9c23c203 | ||
|
|
fc91807e07 | ||
|
|
8aab359b0b | ||
|
|
363c9ff028 | ||
|
|
2a800a825b | ||
|
|
36c1b1e0dd | ||
|
|
01d7cdc7de | ||
|
|
6f25cb9017 | ||
|
|
c4ff479af4 | ||
|
|
cc8406cd79 | ||
|
|
b94c1915a8 | ||
|
|
99a6685845 | ||
|
|
10bf60126e | ||
|
|
14d8f0730f | ||
|
|
618503ccf2 | ||
|
|
f64a837172 |
61
.github/workflows/docker-image.yml
vendored
Normal file
@@ -0,0 +1,61 @@
|
||||
name: Build and Publish Docker Image
|
||||
|
||||
# Trigger the workflow on push to the main branch or on manual dispatch
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
# 1. Checkout the repository
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
# 2. Set up QEMU and Docker Buildx (for multi-platform builds, optional)
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
# 3. Log in to Docker Hub
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: hykilpikonna
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
# 3. Log in to GitHub Container Registry (ghcr.io)
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
# Use GITHUB_TOKEN for authentication
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# 4. Cache Docker layers to speed up builds (optional but recommended)
|
||||
- name: Cache Docker layers
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: /tmp/.buildx-cache
|
||||
key: ${{ runner.os }}-buildx-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-buildx-
|
||||
|
||||
# 5. Build and push the Docker image
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: |
|
||||
hykilpikonna/aquadx:latest
|
||||
platforms: linux/amd64,linux/arm64
|
||||
cache-from: type=local,src=/tmp/.buildx-cache
|
||||
cache-to: type=local,dest=/tmp/.buildx-cache
|
||||
5
.github/workflows/gradle.yml
vendored
@@ -3,6 +3,7 @@ name: Gradle Build
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ master ]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -21,4 +22,6 @@ jobs:
|
||||
uses: gradle/gradle-build-action@v2
|
||||
|
||||
- name: Build with Gradle
|
||||
run: ./gradlew build
|
||||
run: |
|
||||
mkdir data
|
||||
./gradlew build
|
||||
|
||||
42
.github/workflows/nightly.yml
vendored
@@ -4,6 +4,8 @@ name: Nightly Build
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
paths: ['src/**']
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -24,52 +26,26 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/gradle-build-action@v2
|
||||
|
||||
- name: Manage Version
|
||||
run: |
|
||||
git fetch --prune --unshallow --tags
|
||||
GIT_SHA="$(git rev-parse --short HEAD)"
|
||||
CUR_TAG="$(git tag -l | grep 'nightly' | tail -1)"
|
||||
VER="$(sed -n 's/version = "\(.*\)"/\1/p' build.gradle.kts)"
|
||||
echo "SHORT_SHA=$GIT_SHA" >> $GITHUB_ENV
|
||||
echo "VER=$VER" >> $GITHUB_ENV
|
||||
if [[ -z $CUR_TAG ]]; then
|
||||
echo "OLD_PRE_TAG=NULL" >> $GITHUB_ENV
|
||||
else
|
||||
echo "OLD_PRE_TAG=$CUR_TAG" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Build Artifact
|
||||
run: |
|
||||
./gradlew build
|
||||
rm -rf build/libs/*-plain.jar
|
||||
cp build/libs/*.jar "build/libs/aqua-nightly.jar"
|
||||
run: bash ./tools/build.sh
|
||||
|
||||
- name: Generate Prerelease Release Notes
|
||||
- name: Delete previous nightly release
|
||||
run: |
|
||||
echo '### Nightly Release' >> ReleaseNotes.md
|
||||
echo 'This nightly release is automatically built by github actions.' >> ReleaseNotes.md
|
||||
echo '### The latest five updates are:' >> ReleaseNotes.md
|
||||
git log -"5" --format="- %H %s" | sed '/^$/d' >> ReleaseNotes.md
|
||||
|
||||
- name: Delete Old Prerelease
|
||||
if: env.OLD_PRE_TAG != 'NULL'
|
||||
uses: dev-drprasad/delete-tag-and-release@v1.0
|
||||
with:
|
||||
tag_name: ${{ env.OLD_PRE_TAG }}
|
||||
gh release delete --cleanup-tag --yes --repo $GITHUB_REPOSITORY nightly || true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Publish GitHub Release
|
||||
uses: ncipollo/release-action@v1
|
||||
with:
|
||||
bodyFile: ReleaseNotes.md
|
||||
artifacts: "build/libs/aqua-nightly.jar"
|
||||
tag: "${{ env.VER }}-nightly"
|
||||
bodyFile: "build/release/ReleaseNotes.md"
|
||||
artifacts: "build/libs/aqua-nightly.jar,build/aqua-nightly.zip"
|
||||
tag: "nightly"
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
draft: false
|
||||
|
||||
- name: Mark release undraft
|
||||
run: |
|
||||
gh release edit "${{ env.VER }}-nightly" --draft=false
|
||||
gh release edit nightly --draft=false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
9
.gitignore
vendored
@@ -1,3 +1,4 @@
|
||||
db/
|
||||
web/
|
||||
bin/
|
||||
|
||||
@@ -75,4 +76,10 @@ gradle-app.setting
|
||||
|
||||
### Gradle Patch ###
|
||||
# Java heap dump
|
||||
*.hprof
|
||||
*.hprof
|
||||
.jpb
|
||||
src/main/resources/meta/*/*.json
|
||||
*.log.*.gz
|
||||
*.salive
|
||||
test-diff
|
||||
htmlReport
|
||||
|
||||
20
AquaNet/.editorconfig
Normal file
@@ -0,0 +1,20 @@
|
||||
# top-most EditorConfig file
|
||||
root = true
|
||||
|
||||
# Unix-style newlines with a newline ending every file
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
# Matches multiple files: .ts, .json, .svelte .sass
|
||||
[*.{json,svelte,ts,sass}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
|
||||
# Markdown files (e.g., README.md) often use a line length of 80 characters
|
||||
[*.md]
|
||||
max_line_length = 80
|
||||
trim_trailing_whitespace = false
|
||||
41
AquaNet/.eslintrc.cjs
Normal file
@@ -0,0 +1,41 @@
|
||||
// ..eslintrc.cjs example
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: {
|
||||
browser: true,
|
||||
es2023: true
|
||||
},
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:svelte/recommended',
|
||||
],
|
||||
ignorePatterns: ['dist', '..eslintrc.cjs'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module'
|
||||
},
|
||||
rules: {
|
||||
// Custom styling rules
|
||||
'comma-dangle': ['warn', 'only-multiline'],
|
||||
'indent': ['warn', 2],
|
||||
'semi': ['warn', 'never'],
|
||||
'quotes': ['warn', 'single'],
|
||||
'arrow-parens': ['warn', 'as-needed'],
|
||||
'linebreak-style': ['warn', 'unix'],
|
||||
'object-curly-spacing': ['warn', 'always'],
|
||||
'array-bracket-spacing': ["error", "always", {
|
||||
"singleValue": false,
|
||||
"objectsInArrays": false,
|
||||
"arraysInArrays": false
|
||||
}],
|
||||
|
||||
// Disabled rules
|
||||
'no-unused-vars': 'off',
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
'no-constant-condition': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
},
|
||||
}
|
||||
33
AquaNet/.gitignore
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Yarn 3 files
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/sdks
|
||||
!.yarn/versions
|
||||
893
AquaNet/.yarn/releases/yarn-4.1.1.cjs
vendored
Normal file
20
AquaNet/README.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# AquaNet
|
||||
|
||||
This is the codebase for the new frontend of AquaDX.
|
||||
This project is also heavily WIP, so more details will be added later on.
|
||||
|
||||
## Development
|
||||
|
||||
This project uses Svelte (NOT SvelteKit) + TypeScript + Sass, built using Vite. The preferred editor is VSCode.
|
||||
|
||||
### Running locally
|
||||
|
||||
First, you would need to install Node.js and bun.
|
||||
Then, you would need to start your testing AquaDX server and configure the `aqua_host` in `src/libs/config.ts` to use your URL.
|
||||
Please leave `data_host` unchanged if you're not sure what it is.
|
||||
Finally, run:
|
||||
|
||||
```shell
|
||||
bun install
|
||||
bun run dev
|
||||
```
|
||||
BIN
AquaNet/bun.lockb
Normal file
45
AquaNet/index.html
Normal file
@@ -0,0 +1,45 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>AquaDX.net</title>
|
||||
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/assets/icons/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/assets/icons/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/assets/icons/favicon-16x16.png">
|
||||
<link rel="manifest" href="/assets/icons/site.webmanifest">
|
||||
<link rel="mask-icon" href="/assets/icons/safari-pinned-tab.svg" color="#b3c6ff">
|
||||
<link rel="shortcut icon" href="/assets/icons/favicon.ico">
|
||||
<meta name="msapplication-TileColor" content="#ffffff">
|
||||
<meta name="msapplication-config" content="/assets/icons/browserconfig.xml">
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
|
||||
<!-- Font CSS -->
|
||||
<link rel="stylesheet" href="/assets/fonts/Quicksand.400.css" />
|
||||
<link rel="stylesheet" href="/assets/fonts/Quicksand.500.css" />
|
||||
|
||||
<!-- Primary Meta Tags -->
|
||||
<meta name="title" content="AquaDX.net" />
|
||||
<meta name="description" content="A certain magical arcade server 🪄" />
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://aquadx.net/" />
|
||||
<meta property="og:title" content="AquaDX.net" />
|
||||
<meta property="og:description" content="A certain magical arcade server 🪄" />
|
||||
<meta property="og:image" content="https://aquadx.net/assets/meta/meta.png" />
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta property="twitter:card" content="summary_large_image" />
|
||||
<meta property="twitter:url" content="https://aquadx.net/" />
|
||||
<meta property="twitter:title" content="AquaDX.net" />
|
||||
<meta property="twitter:description" content="A certain magical arcade server 🪄" />
|
||||
<meta property="twitter:image" content="https://aquadx.net/assets/meta/meta.png" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
38
AquaNet/nginx.conf.example
Normal file
@@ -0,0 +1,38 @@
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name aqua.example.com;
|
||||
ssl_certificate /etc/nginx/certs/aqua.example.com.crt;
|
||||
ssl_certificate_key /etc/nginx/certs/aqua.example.com.key;
|
||||
|
||||
# Absolute path to store the compiled AquaNet,
|
||||
# there should be index.html in the directory.
|
||||
location / {
|
||||
root /var/www/html/AquaNet/;
|
||||
try_files $uri $uri/ @router;
|
||||
index index.html;
|
||||
}
|
||||
|
||||
# Route for redirection to index.html
|
||||
location @router{
|
||||
rewrite ^.*$ /index.html last;
|
||||
}
|
||||
|
||||
# If you have modified DATA_HOST,
|
||||
# you will need to prepare your own resources.
|
||||
# location /d/ {
|
||||
# root /var/www/html/GameData/;
|
||||
# }
|
||||
|
||||
# Reverse Proxy to your AquaDX.
|
||||
location /aqua/ {
|
||||
proxy_redirect off;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_pass http://127.0.0.1/;
|
||||
proxy_buffering off;
|
||||
}
|
||||
}
|
||||
45
AquaNet/package.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "aqua-net",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-check --tsconfig ./tsconfig.json",
|
||||
"lint": "eslint . --ext ts,tsx,svelte --max-warnings 0 --fix"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify/svelte": "^4.1.0",
|
||||
"@iconify/tools": "^4.1.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||
"@tsconfig/svelte": "^5.0.4",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@types/wicg-file-system-access": "^2023.10.5",
|
||||
"@unocss/svelte-scoped": "^0.65.2",
|
||||
"chartjs-adapter-moment": "^1.0.1",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-plugin-svelte": "^2.46.1",
|
||||
"sass": "^1.83.0",
|
||||
"shiki": "^1.24.4",
|
||||
"svelte": "^5",
|
||||
"svelte-check": "^4",
|
||||
"svelte-turnstile": "^0.9.0",
|
||||
"tslib": "^2.8.1",
|
||||
"typescript": "^5.7.2",
|
||||
"typescript-eslint": "^8.18.2",
|
||||
"unocss": "^0.65.2",
|
||||
"vite": "^6.0.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"cal-heatmap": "^4.2.4",
|
||||
"chart.js": "^4.4.7",
|
||||
"d3": "^7.9.0",
|
||||
"lxgw-wenkai-lite-webfont": "^1.7.0",
|
||||
"modern-normalize": "^3.0.1",
|
||||
"moment": "^2.30.1",
|
||||
"svelte5-router": "^3.0.1"
|
||||
},
|
||||
"packageManager": "pnpm@9.7.0+sha512.dc09430156b427f5ecfc79888899e1c39d2d690f004be70e05230b72cb173d96839587545d09429b55ac3c429c801b4dc3c0e002f653830a420fa2dd4e3cf9cf"
|
||||
}
|
||||
4499
AquaNet/pnpm-lock.yaml
generated
Normal file
BIN
AquaNet/public/assets/email/border.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
27
AquaNet/public/assets/fonts/Quicksand.400.css
Normal file
@@ -0,0 +1,27 @@
|
||||
/* vietnamese */
|
||||
@font-face {
|
||||
font-family: 'Quicksand';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(Quicksand.400.vietnamese.woff2) format('woff2');
|
||||
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
|
||||
}
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Quicksand';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(Quicksand.400.latin-ext.woff2) format('woff2');
|
||||
unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'Quicksand';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(Quicksand.400.latin.woff2) format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
BIN
AquaNet/public/assets/fonts/Quicksand.400.latin-ext.woff2
Normal file
BIN
AquaNet/public/assets/fonts/Quicksand.400.latin.woff2
Normal file
BIN
AquaNet/public/assets/fonts/Quicksand.400.vietnamese.woff2
Normal file
27
AquaNet/public/assets/fonts/Quicksand.500.css
Normal file
@@ -0,0 +1,27 @@
|
||||
/* vietnamese */
|
||||
@font-face {
|
||||
font-family: 'Quicksand';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url(Quicksand.500.vietnamese.woff2) format('woff2');
|
||||
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
|
||||
}
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Quicksand';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url(Quicksand.500.latin-ext.woff2) format('woff2');
|
||||
unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'Quicksand';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url(Quicksand.400.latin.woff2) format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
BIN
AquaNet/public/assets/fonts/Quicksand.500.latin-ext.woff2
Normal file
BIN
AquaNet/public/assets/fonts/Quicksand.500.latin.woff2
Normal file
BIN
AquaNet/public/assets/fonts/Quicksand.500.vietnamese.woff2
Normal file
BIN
AquaNet/public/assets/icons/AquaDX Cat Badge.png
Normal file
|
After Width: | Height: | Size: 6.9 MiB |
BIN
AquaNet/public/assets/icons/AquaDX Cat.128px.webp
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
AquaNet/public/assets/icons/AquaDX Cat.png
Normal file
|
After Width: | Height: | Size: 7.0 MiB |
BIN
AquaNet/public/assets/icons/AquaDX Cat.psd
Normal file
BIN
AquaNet/public/assets/icons/AquaDX Cat.webp
Normal file
|
After Width: | Height: | Size: 778 KiB |
BIN
AquaNet/public/assets/icons/Icon.png
Normal file
|
After Width: | Height: | Size: 2.3 MiB |
BIN
AquaNet/public/assets/icons/Icon.psd
Normal file
BIN
AquaNet/public/assets/icons/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
AquaNet/public/assets/icons/android-chrome-512x512.png
Normal file
|
After Width: | Height: | Size: 98 KiB |
BIN
AquaNet/public/assets/icons/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
9
AquaNet/public/assets/icons/browserconfig.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<browserconfig>
|
||||
<msapplication>
|
||||
<tile>
|
||||
<square150x150logo src="/assets/icons/mstile-150x150.png"/>
|
||||
<TileColor>#ffffff</TileColor>
|
||||
</tile>
|
||||
</msapplication>
|
||||
</browserconfig>
|
||||
BIN
AquaNet/public/assets/icons/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
AquaNet/public/assets/icons/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
AquaNet/public/assets/icons/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
AquaNet/public/assets/icons/mstile-150x150.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
71869
AquaNet/public/assets/icons/safari-pinned-tab.svg
Normal file
|
After Width: | Height: | Size: 4.6 MiB |
19
AquaNet/public/assets/icons/site.webmanifest
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "",
|
||||
"short_name": "",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/assets/icons/android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/assets/icons/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"theme_color": "#ffffff",
|
||||
"background_color": "#ffffff",
|
||||
"display": "standalone"
|
||||
}
|
||||
BIN
AquaNet/public/assets/imgs/All Perfect.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
AquaNet/public/assets/imgs/All Perfect.psd
Normal file
BIN
AquaNet/public/assets/imgs/Full Combo.png
Normal file
|
After Width: | Height: | Size: 68 KiB |
BIN
AquaNet/public/assets/imgs/Full Combo.psd
Normal file
BIN
AquaNet/public/assets/imgs/no_cover.jpg
Normal file
|
After Width: | Height: | Size: 894 KiB |
BIN
AquaNet/public/assets/imgs/no_cover.psd
Normal file
BIN
AquaNet/public/assets/imgs/no_profile.png
Normal file
|
After Width: | Height: | Size: 528 KiB |
BIN
AquaNet/public/assets/imgs/no_profile.psd
Normal file
BIN
AquaNet/public/assets/meta/meta.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
101
AquaNet/src/App.svelte
Normal file
@@ -0,0 +1,101 @@
|
||||
<script lang="ts">
|
||||
import { Route, Router } from "svelte5-router";
|
||||
import Welcome from "./pages/Welcome.svelte";
|
||||
import UserHome from "./pages/UserHome.svelte";
|
||||
import Home from "./pages/Home.svelte";
|
||||
import Ranking from "./pages/Ranking.svelte";
|
||||
import { USER } from "./libs/sdk";
|
||||
import type { AquaNetUser } from "./libs/generalTypes";
|
||||
import Settings from "./pages/User/Settings.svelte";
|
||||
import { pfp } from "./libs/ui"
|
||||
|
||||
console.log(`%c
|
||||
┏━┓ ┳━┓━┓┏━
|
||||
┣━┫┏━┓┓ ┏┏━┓┃ ┃ ┣┫
|
||||
┛ ┗┗━┫┗━┻┗━┻┻━┛━┛┗━
|
||||
┗ v${APP_VERSION}`, `
|
||||
background: linear-gradient(-45deg, rgba(18,194,233,1) 0%, rgba(196,113,237,1) 50%, rgba(246,79,89,1) 100%);
|
||||
font-size: 2em;
|
||||
font-family: Monospace;
|
||||
unicode-bidi: isolate;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;`)
|
||||
|
||||
export let url = "";
|
||||
let me: AquaNetUser
|
||||
|
||||
if (USER.isLoggedIn()) USER.me().then(m => me = m).catch(e => console.error(e))
|
||||
|
||||
let path = window.location.pathname;
|
||||
</script>
|
||||
|
||||
<nav>
|
||||
{#if path !== "/"}
|
||||
<a class="logo" href={USER.isLoggedIn() ? "/home" : "/"}>
|
||||
<img src="/assets/icons/android-chrome-192x192.png" alt="AquaDX"/>
|
||||
<span>AquaNet</span>
|
||||
</a>
|
||||
{/if}
|
||||
<a href="/home">home</a>
|
||||
<div on:click={() => alert("Coming soon™")} on:keydown={e => e.key === "Enter" && alert("Coming soon™")}
|
||||
role="button" tabindex="0">maps</div>
|
||||
<a href="/ranking">rankings</a>
|
||||
{#if me}
|
||||
<a href="/u/{me.username}">
|
||||
<img alt="profile" class="pfp" use:pfp={me}/>
|
||||
</a>
|
||||
{/if}
|
||||
</nav>
|
||||
|
||||
<Router {url}>
|
||||
<Route path="/" component={Welcome} />
|
||||
<Route path="/home" component={Home} />
|
||||
<Route path="/ranking" component={Ranking} />
|
||||
<Route path="/ranking/:game" component={Ranking} />
|
||||
<Route path="/u/:username" component={UserHome} />
|
||||
<Route path="/u/:username/:game" component={UserHome} />
|
||||
<Route path="/settings" component={Settings} />
|
||||
</Router>
|
||||
|
||||
<style lang="sass">
|
||||
@use "vars"
|
||||
|
||||
nav
|
||||
display: flex
|
||||
justify-content: flex-end
|
||||
align-items: center
|
||||
gap: 32px
|
||||
height: vars.$nav-height
|
||||
|
||||
padding: 0 48px
|
||||
|
||||
z-index: 10
|
||||
position: relative
|
||||
|
||||
img
|
||||
width: 1.5rem
|
||||
height: 1.5rem
|
||||
border-radius: 50%
|
||||
object-fit: cover
|
||||
|
||||
.pfp
|
||||
width: 2rem
|
||||
height: 2rem
|
||||
|
||||
.logo
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: 8px
|
||||
font-weight: bold
|
||||
color: vars.$c-main
|
||||
letter-spacing: 0.2em
|
||||
flex: 1
|
||||
|
||||
@media (max-width: vars.$w-mobile)
|
||||
> span
|
||||
display: none
|
||||
|
||||
@media (max-width: vars.$w-mobile)
|
||||
justify-content: center
|
||||
|
||||
</style>
|
||||
349
AquaNet/src/app.sass
Normal file
@@ -0,0 +1,349 @@
|
||||
@use "sass:color"
|
||||
@use "vars"
|
||||
@import 'lxgw-wenkai-lite-webfont/style.css'
|
||||
|
||||
html
|
||||
height: 100%
|
||||
|
||||
body
|
||||
font-family: vars.$font
|
||||
line-height: 1.5
|
||||
font-weight: 400
|
||||
width: 100%
|
||||
height: 100%
|
||||
margin: 0
|
||||
overflow-x: hidden
|
||||
|
||||
color-scheme: dark
|
||||
color: rgba(255, 255, 255, 0.87)
|
||||
background-color: vars.$c-bg
|
||||
|
||||
font-synthesis: none
|
||||
text-rendering: optimizeLegibility
|
||||
-webkit-font-smoothing: antialiased
|
||||
-moz-osx-font-smoothing: grayscale
|
||||
|
||||
a
|
||||
font-weight: 500
|
||||
color: vars.$c-main
|
||||
text-decoration: inherit
|
||||
|
||||
a:hover
|
||||
color: vars.$c-main
|
||||
|
||||
h1
|
||||
font-size: 3.2em
|
||||
line-height: 1.1
|
||||
|
||||
|
||||
.card
|
||||
display: flex
|
||||
flex-direction: column
|
||||
|
||||
border-radius: vars.$border-radius
|
||||
padding: 12px 16px
|
||||
background: vars.$ov-light
|
||||
|
||||
|
||||
blockquote
|
||||
$c1: rgba(255, 149, 149, 0.05)
|
||||
$c2: rgba(255, 152, 152, 0.12)
|
||||
background: repeating-linear-gradient(45deg, $c1, $c1 10px, $c2 10px, $c2 20px)
|
||||
padding: 10px 20px 10px 20px
|
||||
margin: 16px 0
|
||||
border-left: solid #ff7c7c 3px
|
||||
border-radius: vars.$border-radius
|
||||
|
||||
|
||||
#app
|
||||
width: 100%
|
||||
height: 100%
|
||||
|
||||
|
||||
main:not(.no-margin)
|
||||
max-width: 1280px
|
||||
|
||||
margin: 0 auto
|
||||
padding-bottom: 100px
|
||||
|
||||
|
||||
button
|
||||
border-radius: vars.$border-radius
|
||||
border: 1px solid transparent
|
||||
padding: 0.6em 1.2em
|
||||
font-size: 1em
|
||||
font-weight: 500
|
||||
font-family: inherit
|
||||
background-color: vars.$ov-lighter
|
||||
opacity: 0.9
|
||||
cursor: pointer
|
||||
transition: vars.$transition
|
||||
|
||||
button:hover
|
||||
border-color: vars.$c-main
|
||||
|
||||
button:focus, button:focus-visible
|
||||
color: vars.$c-main
|
||||
outline: none
|
||||
|
||||
button.error
|
||||
color: unset
|
||||
&:hover
|
||||
border-color: vars.$c-error
|
||||
color: vars.$c-error
|
||||
//background: $c-error
|
||||
//border-color: transparent
|
||||
|
||||
button.icon
|
||||
padding: 0.6em
|
||||
font-size: 1.2em
|
||||
border-radius: 50px
|
||||
@extend .flex-center
|
||||
|
||||
.level-0
|
||||
//--lv-color: #6ED43E
|
||||
--lv-color: 110, 212, 62
|
||||
|
||||
.level-1
|
||||
//--lv-color: #F7B807
|
||||
--lv-color: 247, 184, 7
|
||||
|
||||
.level-2
|
||||
//--lv-color: #FF828D
|
||||
--lv-color: 255, 130, 141
|
||||
|
||||
.level-3
|
||||
//--lv-color: #A051DC
|
||||
--lv-color: 160, 81, 220
|
||||
|
||||
.level-4
|
||||
//--lv-color: #c299e7
|
||||
--lv-color: 194, 153, 231
|
||||
|
||||
.level-5
|
||||
// World's End for chunithm, or Utage for maimai
|
||||
// --lv-color: #eff2e1
|
||||
// --lv-color: 239, 242, 225
|
||||
--lv-text-clip: linear-gradient(110deg, #5ac42c, #5ccc22, #959f26, #cc7c23, #c93143, #8f4876, #4c3eb1, #3c3397)
|
||||
|
||||
|
||||
.error
|
||||
color: vars.$c-error
|
||||
|
||||
input
|
||||
border-radius: vars.$border-radius
|
||||
border: 1px solid transparent
|
||||
padding: 0.6em 1.2em
|
||||
font-size: 1em
|
||||
font-weight: 500
|
||||
font-family: inherit
|
||||
background-color: vars.$ov-lighter
|
||||
transition: vars.$transition
|
||||
box-sizing: border-box
|
||||
|
||||
// Dropdown
|
||||
select
|
||||
border-radius: vars.$border-radius
|
||||
border: 1px solid transparent
|
||||
padding: 0.6em 1.2em
|
||||
font-size: 1em
|
||||
font-weight: 500
|
||||
font-family: inherit
|
||||
background-color: vars.$ov-lighter
|
||||
transition: vars.$transition
|
||||
box-sizing: border-box
|
||||
|
||||
option
|
||||
background-color: #333
|
||||
color: #fff
|
||||
|
||||
option:hover
|
||||
background-color: #555
|
||||
|
||||
input[type="checkbox"]
|
||||
width: 1.2em
|
||||
height: 1.2em
|
||||
margin: 0
|
||||
padding: 0
|
||||
border: 1px solid vars.$c-main
|
||||
background-color: vars.$ov-lighter
|
||||
appearance: none
|
||||
cursor: pointer
|
||||
flex-shrink: 0
|
||||
|
||||
&:checked
|
||||
background-color: vars.$c-main
|
||||
border-color: vars.$c-main
|
||||
|
||||
label
|
||||
cursor: pointer
|
||||
|
||||
input:focus, input:focus-visible
|
||||
border: 1px solid vars.$c-main
|
||||
outline: none
|
||||
|
||||
input.error
|
||||
border: 1px solid vars.$c-error
|
||||
|
||||
.flex-center
|
||||
display: flex
|
||||
justify-content: center
|
||||
align-items: center
|
||||
|
||||
|
||||
.inline-flex-center
|
||||
display: inline-flex
|
||||
justify-content: center
|
||||
align-items: center
|
||||
|
||||
.clickable
|
||||
cursor: pointer
|
||||
user-select: none
|
||||
|
||||
|
||||
// Content containers
|
||||
.content-main
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: 20px
|
||||
margin: 100px auto 0
|
||||
padding: 32px 32px 128px
|
||||
min-height: 100%
|
||||
max-width: vars.$w-max
|
||||
|
||||
background-color: color.adjust(vars.$c-bg, $lightness: -3%)
|
||||
border-radius: 16px 16px 0 0
|
||||
|
||||
@media (max-width: #{vars.$w-max + (64px) * 2})
|
||||
margin: 100px 32px 0
|
||||
|
||||
@media (max-width: vars.$w-mobile)
|
||||
margin: 100px 0 0
|
||||
|
||||
.fw-block
|
||||
margin-left: -32px
|
||||
margin-right: -32px
|
||||
padding: 12px 32px
|
||||
background-color: vars.$ov-darker
|
||||
// Inner shadow
|
||||
box-shadow: inset 0 10px 10px -2px vars.$c-shadow, inset 0 -10px 10px -2px vars.$c-shadow
|
||||
|
||||
> h2.outer-title, > .outer-title-options
|
||||
margin-top: -5rem
|
||||
margin-bottom: 1rem
|
||||
|
||||
@media (max-width: vars.$w-mobile)
|
||||
text-align: center
|
||||
|
||||
> .outer-title-options
|
||||
display: flex
|
||||
justify-content: space-between
|
||||
align-items: center
|
||||
|
||||
nav
|
||||
display: flex
|
||||
flex-direction: row
|
||||
gap: 10px
|
||||
top: 4px
|
||||
|
||||
@media (max-width: vars.$w-mobile)
|
||||
flex-direction: column
|
||||
|
||||
> h2, > .outer-title-options > h2
|
||||
margin: 0
|
||||
|
||||
main.content
|
||||
@extend .content-main
|
||||
|
||||
// Not used. still need a lot of work
|
||||
.content-popup
|
||||
position: absolute
|
||||
inset: 0
|
||||
|
||||
> div
|
||||
@extend .content-main
|
||||
position: absolute
|
||||
inset: 0
|
||||
top: 100px
|
||||
background: rgba(color.adjust(vars.$c-bg, $lightness: -3%), 0.9)
|
||||
backdrop-filter: blur(5px)
|
||||
box-shadow: 0 0 10px 6px rgba(black, 0.4)
|
||||
max-width: calc(vars.$w-max + 20px)
|
||||
|
||||
@media (max-width: #{vars.$w-max + (64px) * 2})
|
||||
margin: 100px 22px 0
|
||||
|
||||
@media (max-width: vars.$w-mobile)
|
||||
margin: 100px 0 0
|
||||
|
||||
|
||||
|
||||
// Overlay
|
||||
.overlay
|
||||
position: fixed
|
||||
inset: 0
|
||||
|
||||
background-color: rgba(0, 0, 0, 0.5)
|
||||
|
||||
display: flex
|
||||
justify-content: center
|
||||
align-items: center
|
||||
|
||||
z-index: 1000
|
||||
backdrop-filter: blur(5px)
|
||||
|
||||
h2, p
|
||||
user-select: none
|
||||
margin: 0
|
||||
|
||||
> div
|
||||
background-color: vars.$c-bg
|
||||
padding: 2rem
|
||||
border-radius: vars.$border-radius
|
||||
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: 1rem
|
||||
|
||||
max-width: 400px
|
||||
|
||||
.no-margin
|
||||
margin: 0
|
||||
|
||||
nav
|
||||
> div, > a
|
||||
cursor: pointer
|
||||
transition: vars.$transition
|
||||
text-decoration: underline 1px solid transparent
|
||||
text-underline-offset: 0.1em
|
||||
|
||||
display: flex
|
||||
align-items: center
|
||||
color: unset
|
||||
font-weight: unset
|
||||
|
||||
&:hover
|
||||
color: vars.$c-main
|
||||
text-decoration-color: vars.$c-main
|
||||
text-underline-offset: 0.5em
|
||||
|
||||
&.active
|
||||
color: vars.$c-main
|
||||
|
||||
|
||||
.hide-scrollbar
|
||||
&::-webkit-scrollbar
|
||||
display: none
|
||||
-ms-overflow-style: none
|
||||
scrollbar-width: none
|
||||
|
||||
|
||||
.aqua-tooltip
|
||||
box-shadow: 0 0 5px 0 vars.$c-shadow
|
||||
border-radius: vars.$border-radius
|
||||
position: absolute
|
||||
padding: 4px 8px
|
||||
background: vars.$ov-lighter
|
||||
backdrop-filter: blur(5px)
|
||||
|
||||
|
||||
76
AquaNet/src/components/ActionCard.svelte
Normal file
@@ -0,0 +1,76 @@
|
||||
<!-- Svelte 4.2.11 -->
|
||||
|
||||
<script lang="ts">
|
||||
import Icon from "@iconify/svelte";
|
||||
|
||||
export let color: string = '179, 198, 255'
|
||||
export let icon: string
|
||||
|
||||
// Manually positioned icons
|
||||
const iconPos = [
|
||||
[1, 0.5, 2],
|
||||
[6, 2, 1.5],
|
||||
[-0.5, 4.5, 1.3],
|
||||
[5, -0.5],
|
||||
[3.5, 4.5],
|
||||
[9.5, 0.3, 1.2],
|
||||
[12.5, 2.5, 0.8],
|
||||
[10, 4.4, 0.8],
|
||||
]
|
||||
</script>
|
||||
|
||||
<div class="action-card" style="--card-color: {color}" on:click role="button" tabindex="0" on:keydown>
|
||||
<slot/>
|
||||
|
||||
<div class="icons">
|
||||
{#each iconPos as [x, y, size], i}
|
||||
<Icon icon={icon} style={`top: ${y}rem; right: ${x}rem; font-size: ${size || 1}em`} />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="sass">
|
||||
@use '../vars'
|
||||
|
||||
.action-card
|
||||
overflow: hidden
|
||||
padding: 1rem
|
||||
border-radius: vars.$border-radius
|
||||
box-shadow: 0 5px 5px 1px vars.$c-shadow
|
||||
transition: all 0.2s ease
|
||||
cursor: pointer
|
||||
position: relative
|
||||
background: linear-gradient(45deg, transparent 20%, rgba(var(--card-color), 0.5) 100%)
|
||||
outline: 1px solid transparent
|
||||
filter: drop-shadow(0 0 12px rgba(var(--card-color), 0))
|
||||
|
||||
&:hover
|
||||
box-shadow: 0 0 0.5rem 0.2rem vars.$c-shadow
|
||||
transform: translateY(-3px)
|
||||
|
||||
// Drop shadow glow
|
||||
filter: drop-shadow(0 0 12px rgba(var(--card-color), 0.5))
|
||||
outline-color: rgba(var(--card-color), 0.5)
|
||||
|
||||
:global(span)
|
||||
font-size: 1.2rem
|
||||
display: block
|
||||
margin-bottom: 0.5rem
|
||||
|
||||
.icons
|
||||
position: absolute
|
||||
inset: 0
|
||||
color: rgba(var(--card-color), 0.5)
|
||||
font-size: 2rem
|
||||
transition: all 0.2s ease
|
||||
z-index: -1
|
||||
mask-image: linear-gradient(45deg, transparent 30%, rgba(255,255,255,0.5) 70%, white 100%)
|
||||
opacity: 0.8
|
||||
|
||||
@media (max-width: vars.$w-mobile)
|
||||
opacity: 0.6
|
||||
|
||||
:global(> svg)
|
||||
position: absolute
|
||||
rotate: 20deg
|
||||
</style>
|
||||
62
AquaNet/src/components/CommunityCard.svelte
Normal file
@@ -0,0 +1,62 @@
|
||||
<!-- Svelte 4.2.11 -->
|
||||
|
||||
<script lang="ts">
|
||||
import Icon from "@iconify/svelte";
|
||||
|
||||
export let color: string = '179, 198, 255'
|
||||
export let icon: string
|
||||
|
||||
</script>
|
||||
|
||||
<div class="action-card" style="--card-color: {color}" on:click role="button" tabindex="0" on:keydown>
|
||||
<slot/>
|
||||
|
||||
<div class="icon">
|
||||
<Icon icon={icon} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="sass">
|
||||
@use '../vars'
|
||||
|
||||
.action-card
|
||||
overflow: hidden
|
||||
padding: 1rem
|
||||
border-radius: vars.$border-radius
|
||||
box-shadow: 0 5px 5px 1px vars.$c-shadow
|
||||
transition: all 0.2s ease
|
||||
cursor: pointer
|
||||
position: relative
|
||||
background: linear-gradient(45deg, transparent 20%, rgba(var(--card-color), 0.5) 100%)
|
||||
outline: 1px solid transparent
|
||||
filter: drop-shadow(0 0 12px rgba(var(--card-color), 0))
|
||||
|
||||
&:hover
|
||||
box-shadow: 0 0 0.5rem 0.2rem vars.$c-shadow
|
||||
transform: translateY(-3px)
|
||||
|
||||
// Drop shadow glow
|
||||
filter: drop-shadow(0 0 12px rgba(var(--card-color), 0.5))
|
||||
outline-color: rgba(var(--card-color), 0.5)
|
||||
|
||||
:global(span)
|
||||
font-size: 1.2rem
|
||||
display: block
|
||||
margin-bottom: 0.5rem
|
||||
|
||||
.icon
|
||||
position: absolute
|
||||
display: flex
|
||||
color: rgba(var(--card-color), 0.5)
|
||||
font-size: 4rem
|
||||
right: 0
|
||||
bottom: 0
|
||||
padding: .5rem
|
||||
transition: all 0.2s ease
|
||||
z-index: -1
|
||||
mask-image: linear-gradient(45deg, transparent 30%, rgba(255,255,255,0.5) 70%, white 100%)
|
||||
opacity: 0.8
|
||||
|
||||
@media (max-width: vars.$w-mobile)
|
||||
opacity: 0.6
|
||||
</style>
|
||||
64
AquaNet/src/components/RankDetails.svelte
Normal file
@@ -0,0 +1,64 @@
|
||||
<!-- Svelte 4.2.11 -->
|
||||
|
||||
<script lang="ts">
|
||||
import { slide } from "svelte/transition";
|
||||
import { t } from "../libs/i18n";
|
||||
import type { GenericGameSummary } from "../libs/generalTypes";
|
||||
|
||||
export let g: GenericGameSummary
|
||||
|
||||
const detail = Object.entries(g.detailedRanks).toSorted((a, b) => +b[0] - +a[0])
|
||||
</script>
|
||||
|
||||
<div class="rank-detail-container fw-block" transition:slide>
|
||||
<div>
|
||||
<h2>{t("UserHome.RankDetail.Title")}</h2>
|
||||
<table><tbody>
|
||||
<!-- rankDetails: { Level : { Rank : Count } } -->
|
||||
<!-- Rows are levels, columns are ranks -->
|
||||
|
||||
<!-- Headers -->
|
||||
<tr>
|
||||
<th>{t("UserHome.RankDetail.Level")}</th>
|
||||
{#each Object.values(g.ranks) as rankMap}<th>{rankMap.name}</th>{/each}
|
||||
</tr>
|
||||
|
||||
<!-- Data -->
|
||||
{#each detail as [level, rankMap]}
|
||||
<tr>
|
||||
<td>{level}</td>
|
||||
{#each Object.values(rankMap) as count}<td>{count}</td>{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="sass">
|
||||
@use "../vars"
|
||||
|
||||
.rank-detail-container
|
||||
> div
|
||||
margin: 1em auto
|
||||
max-width: 500px
|
||||
|
||||
table
|
||||
width: 100%
|
||||
border-collapse: collapse
|
||||
table-layout: fixed
|
||||
|
||||
th:not(:first-child)
|
||||
background: vars.$grad-special
|
||||
-webkit-background-clip: text
|
||||
-webkit-text-fill-color: transparent
|
||||
background-clip: text
|
||||
color: vars.$c-main
|
||||
padding: 0.5em
|
||||
|
||||
th, td
|
||||
padding: 0.5em
|
||||
text-align: center
|
||||
|
||||
&:first-child
|
||||
color: vars.$c-main
|
||||
</style>
|
||||
143
AquaNet/src/components/RatingCompSong.svelte
Normal file
@@ -0,0 +1,143 @@
|
||||
<!-- Svelte 4.2.11 -->
|
||||
|
||||
<script lang="ts">
|
||||
import { slide } from "svelte/transition";
|
||||
import { t } from "../libs/i18n";
|
||||
import { type GameName, type ParsedComposition, roundFloor } from "../libs/scoring";
|
||||
import { coverNotFound } from "../libs/ui";
|
||||
import { tooltip } from "../libs/ui";
|
||||
import useLocalStorage from "../libs/hooks/useLocalStorage.svelte";
|
||||
|
||||
export let game: GameName
|
||||
export let p: ParsedComposition
|
||||
const rounding = useLocalStorage("rounding", true)
|
||||
</script>
|
||||
|
||||
<div class="map-detail-container" transition:slide>
|
||||
<div class="scores">
|
||||
<div>
|
||||
<img src={p.img} alt="" on:error={coverNotFound} />
|
||||
<div class="info">
|
||||
<div class="first-line">
|
||||
<div class="song-title">{p.name ?? t("UserHome.UnknownSong")}</div>
|
||||
<span class={`lv level-${p.diffId === 10 ? 3 : p.diffId}`}>
|
||||
{ p.difficulty ?? '-' }
|
||||
</span>
|
||||
</div>
|
||||
<div class="second-line">
|
||||
<span class={`rank-${p.rank[0]}`}>
|
||||
<span class="rank-text">{p.rank.replace("p", "+")}</span>
|
||||
<span class="rank-num" use:tooltip={(p.score / 10000).toFixed(4)}>
|
||||
{rounding.value ? roundFloor(p.score, game, 1) : (p.score / 10000).toFixed(4)}%
|
||||
</span>
|
||||
</span>
|
||||
{#if p.ratingChange !== undefined}
|
||||
<span class="dx-change">{ p.ratingChange }</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="sass">
|
||||
|
||||
@use "../vars"
|
||||
vars.$gap: 20px
|
||||
|
||||
.map-detail-container
|
||||
background-color: rgb(35,35,35)
|
||||
border-radius: vars.$border-radius
|
||||
overflow: hidden
|
||||
|
||||
.scores
|
||||
display: flex
|
||||
flex-direction: column
|
||||
flex-wrap: wrap
|
||||
gap: vars.$gap
|
||||
|
||||
// Image and song info
|
||||
> div
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: 12px
|
||||
max-width: 100%
|
||||
box-sizing: border-box
|
||||
|
||||
img
|
||||
width: 50px
|
||||
height: 50px
|
||||
border-radius: vars.$border-radius
|
||||
object-fit: cover
|
||||
|
||||
// Song info and score
|
||||
> div.info
|
||||
flex: 1
|
||||
display: flex
|
||||
justify-content: space-between
|
||||
overflow: hidden
|
||||
flex-direction: column
|
||||
|
||||
.first-line
|
||||
display: flex
|
||||
flex-direction: row
|
||||
|
||||
// Limit song name to one line
|
||||
.song-title
|
||||
flex: 1
|
||||
min-width: 0
|
||||
overflow: hidden
|
||||
text-overflow: ellipsis
|
||||
white-space: nowrap
|
||||
|
||||
// Make song score and rank not wrap
|
||||
> div:last-child
|
||||
white-space: nowrap
|
||||
|
||||
@media (max-width: vars.$w-mobile)
|
||||
flex-direction: column
|
||||
gap: 0
|
||||
|
||||
.rank-text
|
||||
text-align: left
|
||||
|
||||
.rank-S
|
||||
// Gold green gradient on text
|
||||
background: vars.$grad-special
|
||||
-webkit-background-clip: text
|
||||
color: transparent
|
||||
|
||||
.rank-A
|
||||
color: #ff8a8a
|
||||
|
||||
.rank-B
|
||||
color: #6ba6ff
|
||||
|
||||
.lv
|
||||
width: 30px
|
||||
text-align: center
|
||||
background: rgba(var(--lv-color), 0.6)
|
||||
padding: 0 6px
|
||||
border-radius: 0 vars.$border-radius 0 vars.$border-radius
|
||||
|
||||
// Inset shadow, like it's a paper below this card with a cut
|
||||
box-shadow: inset 0 0 10px rgba(0,0,0,0.5)
|
||||
|
||||
span
|
||||
display: inline-block
|
||||
text-align: left
|
||||
|
||||
.second-line
|
||||
display: flex
|
||||
justify-content: space-between
|
||||
align-items: center
|
||||
|
||||
// Vertical table-like alignment
|
||||
span.rank-text
|
||||
min-width: 40px
|
||||
span.rank-num
|
||||
min-width: 60px
|
||||
span.dx-change
|
||||
margin-right: 0.5rem
|
||||
color: vars.$c-good
|
||||
</style>
|
||||
42
AquaNet/src/components/RatingComposition.svelte
Normal file
@@ -0,0 +1,42 @@
|
||||
<!-- Svelte 4.2.11 -->
|
||||
|
||||
<script lang="ts">
|
||||
import RatingCompSong from "./RatingCompSong.svelte";
|
||||
import { parseComposition, type GameName } from "../libs/scoring";
|
||||
import { type MusicMeta } from "../libs/generalTypes";
|
||||
|
||||
export let title: string;
|
||||
export let comp: string | undefined;
|
||||
export let allMusics: Record<string, MusicMeta>;
|
||||
export let game: GameName;
|
||||
export let top: number | undefined = undefined;
|
||||
|
||||
let split = comp?.split(",")?.filter(it => it.split(":")[0] !== '0')
|
||||
?.map(it => parseComposition(it, allMusics, game))
|
||||
|
||||
if (top) split = split?.toSorted((a, b) => b.score - a.score).slice(0, top)
|
||||
if (split) console.log("Split", split)
|
||||
</script>
|
||||
|
||||
{#if split && comp}
|
||||
<div>
|
||||
<h2>{title}</h2>
|
||||
<div class="rating-composition">
|
||||
{#each split as p}
|
||||
<div>
|
||||
<RatingCompSong {p} {game}/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style lang="sass">
|
||||
@use "../vars"
|
||||
|
||||
.rating-composition
|
||||
display: grid
|
||||
// 3 columns
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr))
|
||||
gap: vars.$gap
|
||||
</style>
|
||||
87
AquaNet/src/components/StatusOverlays.svelte
Normal file
@@ -0,0 +1,87 @@
|
||||
<!-- Svelte 4.2.11 -->
|
||||
|
||||
<script lang="ts">
|
||||
import { fade } from 'svelte/transition'
|
||||
import type { ConfirmProps } from "../libs/generalTypes";
|
||||
import { DISCORD_INVITE } from "../libs/config";
|
||||
import Icon from "@iconify/svelte";
|
||||
import { t } from "../libs/i18n"
|
||||
|
||||
// Props
|
||||
export let confirm: ConfirmProps | null = null
|
||||
export let error: string | null
|
||||
export let loading: boolean = false
|
||||
</script>
|
||||
|
||||
{#if confirm}
|
||||
<div class="overlay" transition:fade>
|
||||
<div>
|
||||
<h2>{confirm.title}</h2>
|
||||
<span>{confirm.message}</span>
|
||||
|
||||
<div class="actions">
|
||||
{#if confirm.cancel}
|
||||
<!-- Svelte LSP is very annoying here -->
|
||||
<button on:click={() => {
|
||||
confirm && confirm.cancel && confirm.cancel()
|
||||
|
||||
// Set to null
|
||||
confirm = null
|
||||
}}>{t('action.cancel')}</button>
|
||||
{/if}
|
||||
<button on:click={() => confirm && confirm.confirm()} class:error={confirm.dangerous}>{t('action.confirm')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<div class="overlay" transition:fade>
|
||||
<div>
|
||||
<h2 class="error">{t('status.error')}</h2>
|
||||
<span>{t('status.error.hint')}<a href={DISCORD_INVITE}>{t('status.error.hint.link')}</a></span>
|
||||
<span>{t('status.detail', { detail: error })}</span>
|
||||
|
||||
<div class="actions">
|
||||
<button on:click={() => location.reload()} class="error">
|
||||
{t('action.refresh')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if loading && !error}
|
||||
<div class="overlay loading" transition:fade>
|
||||
<Icon class="icon" icon="svg-spinners:pulse-2"/>
|
||||
<span><span>LOADING</span></span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style lang="sass">
|
||||
.actions
|
||||
display: flex
|
||||
gap: 16px
|
||||
|
||||
button
|
||||
width: 100%
|
||||
|
||||
.loading.overlay
|
||||
font-size: 28rem
|
||||
|
||||
:global(.icon)
|
||||
opacity: 0.5
|
||||
|
||||
> span
|
||||
position: absolute
|
||||
inset: 0
|
||||
display: flex
|
||||
justify-content: center
|
||||
align-items: center
|
||||
background: transparent
|
||||
|
||||
letter-spacing: 20px
|
||||
margin-left: 20px
|
||||
|
||||
font-size: 1.5rem
|
||||
</style>
|
||||
76
AquaNet/src/components/Tooltip.svelte
Normal file
@@ -0,0 +1,76 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte'
|
||||
|
||||
export let triggeredBy: string
|
||||
export let loading: boolean = false
|
||||
let isHovered = false
|
||||
let x: number, y: number
|
||||
let targets: Element[] = []
|
||||
|
||||
onMount(() => {
|
||||
targets = [...document.querySelectorAll(triggeredBy)]
|
||||
targets.forEach((el) => {
|
||||
el.addEventListener('mouseover', mouseOver)
|
||||
el.addEventListener('mousemove', mouseMove)
|
||||
el.addEventListener('mouseleave', mouseLeave)
|
||||
})
|
||||
if (targets.length === 0) {
|
||||
console.warn(`No elements found with selector "${triggeredBy}"`)
|
||||
}
|
||||
})
|
||||
|
||||
onDestroy(() => {
|
||||
targets.forEach((el) => {
|
||||
el.removeEventListener('mouseover', mouseOver)
|
||||
el.removeEventListener('mousemove', mouseMove)
|
||||
el.removeEventListener('mouseleave', mouseLeave)
|
||||
})
|
||||
})
|
||||
|
||||
function mouseOver(event: MouseEvent) {
|
||||
console.log('over')
|
||||
isHovered = true
|
||||
updatePosition(event)
|
||||
}
|
||||
|
||||
function mouseMove(event: MouseEvent) {
|
||||
console.log('move')
|
||||
updatePosition(event)
|
||||
}
|
||||
|
||||
function updatePosition(event: MouseEvent) {
|
||||
x = event.pageX + 5
|
||||
y = event.pageY + 20
|
||||
}
|
||||
|
||||
function mouseLeave() {
|
||||
console.log('leave')
|
||||
isHovered = false
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if isHovered}
|
||||
<div style="top: {y}px; left: {x}px" class="tooltip" class:loading>
|
||||
<slot />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style lang="sass">
|
||||
@use "../vars"
|
||||
|
||||
.tooltip
|
||||
position: absolute
|
||||
z-index: 1000
|
||||
background: white
|
||||
padding: 10px 16px
|
||||
border-radius: vars.$border-radius
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1)
|
||||
pointer-events: none
|
||||
white-space: nowrap
|
||||
color: #242424
|
||||
transform: translate(-50%, 0)
|
||||
transition: opacity 0.2s
|
||||
|
||||
&.loading
|
||||
opacity: 0
|
||||
</style>
|
||||
56
AquaNet/src/components/UserCard.svelte
Normal file
@@ -0,0 +1,56 @@
|
||||
<!-- Svelte 4.2.11 -->
|
||||
|
||||
<script lang="ts">
|
||||
import type { GenericGameSummary } from "../libs/generalTypes";
|
||||
import { GAME } from "../libs/sdk";
|
||||
import type { GameName } from "../libs/scoring";
|
||||
import { pfp, pfpNotFound } from "../libs/ui";
|
||||
|
||||
export let username: string
|
||||
export let game: GameName
|
||||
export let setLoading: (loading: boolean) => void = () => {}
|
||||
|
||||
let data: GenericGameSummary
|
||||
let error = ""
|
||||
|
||||
setLoading(true)
|
||||
GAME.userSummary(username, game).then(d => data = d).catch(e => error = e).finally(_ => setLoading(false))
|
||||
</script>
|
||||
|
||||
{#if !data}
|
||||
<div>Loading...</div>
|
||||
{:else if error}
|
||||
<div>Error: {error}</div>
|
||||
{:else}
|
||||
<div class="user-card">
|
||||
<img use:pfp={data.aquaUser} alt="Profile" />
|
||||
<div class="details">
|
||||
<span class="in-game-name">{data.name}</span>
|
||||
<span class="username">@{username}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style lang="sass">
|
||||
@use "../vars"
|
||||
|
||||
.user-card
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: vars.$gap
|
||||
|
||||
.details
|
||||
display: flex
|
||||
flex-direction: column
|
||||
justify-content: center
|
||||
|
||||
.username
|
||||
font-size: 0.8em
|
||||
|
||||
img
|
||||
width: 50px
|
||||
height: 50px
|
||||
border-radius: 50%
|
||||
object-fit: cover
|
||||
object-position: center
|
||||
</style>
|
||||
29
AquaNet/src/components/chart/Line.svelte
Normal file
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import { Chart, Tooltip, type ChartData, type ChartOptions } from 'chart.js';
|
||||
import type { HTMLCanvasAttributes } from 'svelte/elements';
|
||||
import 'chart.js/auto';
|
||||
|
||||
interface Props extends HTMLCanvasAttributes {
|
||||
data: ChartData<'line', any, string>
|
||||
options: ChartOptions<'line'>
|
||||
}
|
||||
const { data, options, ...rest }: Props = $props()
|
||||
|
||||
Chart.register(Tooltip)
|
||||
|
||||
let canvasElem: HTMLCanvasElement
|
||||
let chart: Chart
|
||||
|
||||
$effect(() => {
|
||||
chart = new Chart(canvasElem, { type: 'line', data, options })
|
||||
return () => chart.destroy()
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (!chart) return
|
||||
chart.data = data
|
||||
chart.update()
|
||||
})
|
||||
</script>
|
||||
|
||||
<canvas bind:this={canvasElem} {...rest}></canvas>
|
||||
456
AquaNet/src/components/settings/ChuniSettings.svelte
Normal file
@@ -0,0 +1,456 @@
|
||||
<!-- Svelte 4.2.11 -->
|
||||
|
||||
<script lang="ts">
|
||||
import {
|
||||
type AquaNetUser,
|
||||
type UserBox,
|
||||
type UserItem,
|
||||
} from "../../libs/generalTypes";
|
||||
import { DATA, USER, USERBOX } from "../../libs/sdk";
|
||||
import { t, ts } from "../../libs/i18n";
|
||||
import { DATA_HOST, FADE_IN, FADE_OUT, HAS_USERBOX_ASSETS } from "../../libs/config";
|
||||
import { fade, slide } from "svelte/transition";
|
||||
import StatusOverlays from "../StatusOverlays.svelte";
|
||||
import Icon from "@iconify/svelte";
|
||||
import GameSettingFields from "./GameSettingFields.svelte";
|
||||
import { filter } from "d3";
|
||||
import { coverNotFound } from "../../libs/ui";
|
||||
|
||||
import { userboxFileProcess, ddsDB, initializeDb } from "../../libs/userbox/userbox"
|
||||
|
||||
import ChuniPenguinComponent from "./userbox/ChuniPenguin.svelte"
|
||||
import ChuniUserplateComponent from "./userbox/ChuniUserplate.svelte";
|
||||
|
||||
import useLocalStorage from "../../libs/hooks/useLocalStorage.svelte";
|
||||
import { DDS } from "../../libs/userbox/dds";
|
||||
|
||||
let user: AquaNetUser
|
||||
let [loading, error, submitting, preview] = [true, "", "", ""]
|
||||
let changed: string[] = [];
|
||||
|
||||
// Available (unlocked) options for each kind of item
|
||||
// In allItems: 'namePlate', 'frame', 'trophy', 'mapIcon', 'systemVoice', 'avatarAccessory'
|
||||
let allItems: Record<string, Record<string, { name: string }>> = {}
|
||||
let iKinds = { namePlate: 1, frame: 2, trophy: 3, mapIcon: 8, systemVoice: 9, avatarAccessory: 11 }
|
||||
// In userbox: 'nameplateId', 'frameId', 'trophyId', 'mapIconId', 'voiceId', 'avatar{Wear/Head/Face/Skin/Item/Front/Back}'
|
||||
let userbox: UserBox
|
||||
let avatarKinds = ['Wear', 'Head', 'Face', 'Skin', 'Item', 'Front', 'Back'] as const
|
||||
// iKey should match allItems keys, and ubKey should match userbox keys
|
||||
let userItems: { iKey: string, ubKey: keyof UserBox, items: UserItem[] }[] = []
|
||||
|
||||
// Submit changes
|
||||
function submit(field: keyof UserBox) {
|
||||
let obj = { field, value: userbox[field] }
|
||||
if (submitting) return
|
||||
submitting = obj.field
|
||||
|
||||
USERBOX.setUserBox(obj)
|
||||
.then(() => changed = changed.filter((c) => c !== obj.field))
|
||||
.catch(e => error = e.message)
|
||||
.finally(() => submitting = "")
|
||||
}
|
||||
|
||||
// Fetch data from the server
|
||||
async function fetchData() {
|
||||
const profile = await USERBOX.getProfile().catch(_ => {
|
||||
loading = false
|
||||
error = t("userbox.error.nodata")
|
||||
})
|
||||
if (!profile) return
|
||||
userbox = profile.user
|
||||
userItems = Object.entries(iKinds).flatMap(([iKey, iKind]) => {
|
||||
if (iKey != 'avatarAccessory') {
|
||||
let ubKey = `${iKey}Id`
|
||||
if (ubKey == 'namePlateId') ubKey = 'nameplateId'
|
||||
if (ubKey == 'systemVoiceId') ubKey = 'voiceId'
|
||||
return [{ iKey, ubKey: ubKey as keyof UserBox,
|
||||
items: profile.items.filter(x => x.itemKind === iKind)
|
||||
}]
|
||||
}
|
||||
|
||||
return avatarKinds.map((aKind, i) => {
|
||||
let items = profile.items.filter(x => x.itemKind === iKind && Math.floor(x.itemId / 100000) % 10 === i + 1)
|
||||
return { iKey, ubKey: `avatar${aKind}` as keyof UserBox, items }
|
||||
})
|
||||
})
|
||||
|
||||
allItems = await DATA.allItems('chu3').catch(_ => {
|
||||
loading = false
|
||||
error = t("userbox.error.nodata")
|
||||
}) as typeof allItems
|
||||
|
||||
console.log("User Items", userItems)
|
||||
console.log("All items", allItems)
|
||||
console.log("Userbox", userbox)
|
||||
|
||||
loading = false
|
||||
}
|
||||
|
||||
USER.me().then(u => {
|
||||
if (!u) throw new Error(t("userbox.error.nodata"))
|
||||
user = u
|
||||
return fetchData()
|
||||
}).catch((e) => { loading = false; error = e.message });
|
||||
|
||||
let DDSreader: DDS | undefined;
|
||||
|
||||
let USERBOX_PROGRESS = 0;
|
||||
let USERBOX_SETUP_RUN = false;
|
||||
let USERBOX_SETUP_TEXT = t("userbox.new.setup");
|
||||
|
||||
let USERBOX_ENABLED = useLocalStorage("userboxNew", false);
|
||||
let USERBOX_INSTALLED = false;
|
||||
let USERBOX_SUPPORT = "webkitGetAsEntry" in DataTransferItem.prototype;
|
||||
|
||||
type OnlyNumberPropsOf<T extends Record<string, any>> = {[Prop in keyof T as (T[Prop] extends number ? Prop : never)]: T[Prop]}
|
||||
let userboxSelected: keyof OnlyNumberPropsOf<UserBox> = "avatarWear";
|
||||
const userboxNewOptions = ["systemVoice", "frame", "trophy", "mapIcon"]
|
||||
|
||||
async function userboxSafeDrop(event: Event & { currentTarget: EventTarget & HTMLInputElement; }) {
|
||||
if (!event.target) return null;
|
||||
let input = event.target as HTMLInputElement;
|
||||
let folder = input.webkitEntries[0];
|
||||
error = await userboxFileProcess(folder, (progress: number, progressString: string) => {
|
||||
USERBOX_SETUP_TEXT = progressString;
|
||||
USERBOX_PROGRESS = progress;
|
||||
}) ?? "";
|
||||
}
|
||||
|
||||
indexedDB.databases().then(async (dbi) => {
|
||||
let databaseExists = dbi.some(db => db.name == "userboxChusanDDS");
|
||||
if (databaseExists) {
|
||||
await initializeDb();
|
||||
DDSreader = new DDS(ddsDB);
|
||||
USERBOX_INSTALLED = databaseExists;
|
||||
}
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<StatusOverlays {error} loading={loading || !!submitting} />
|
||||
{#if !loading && !error}
|
||||
<div out:fade={FADE_OUT} in:fade={FADE_IN}>
|
||||
<h2>{t("userbox.header.general")}</h2>
|
||||
<GameSettingFields game="chu3"/>
|
||||
<h2>{t("userbox.header.userbox")}</h2>
|
||||
{#if !USERBOX_ENABLED.value || !USERBOX_INSTALLED}
|
||||
<div class="fields">
|
||||
{#each userItems as { iKey, ubKey, items }, i}
|
||||
<div class="field">
|
||||
<label for={ubKey}>{ts(`userbox.${ubKey}`)}</label>
|
||||
<div>
|
||||
<select bind:value={userbox[ubKey]} id={ubKey} on:change={() => changed = [...changed, ubKey]}>
|
||||
{#each items as option}
|
||||
<option value={option.itemId}>{allItems[iKey][option.itemId]?.name || `(unknown ${option.itemId})`}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{#if changed.includes(ubKey)}
|
||||
<button transition:slide={{axis: "x"}} on:click={() => submit(ubKey)} disabled={!!submitting}>
|
||||
{t("settings.profile.save")}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="chuni-userbox-container">
|
||||
<ChuniUserplateComponent on:click={() => userboxSelected = "nameplateId"} chuniCharacter={userbox.characterId} chuniLevel={userbox.level} chuniRating={userbox.playerRating / 100}
|
||||
chuniNameplate={userbox.nameplateId} chuniName={userbox.userName} chuniTrophyName={allItems.trophy[userbox.trophyId].name}></ChuniUserplateComponent>
|
||||
<ChuniPenguinComponent classPassthrough="chuni-penguin-float" chuniWear={userbox.avatarWear} chuniHead={userbox.avatarHead} chuniBack={userbox.avatarBack}
|
||||
chuniFront={userbox.avatarFront} chuniFace={userbox.avatarFace} chuniItem={userbox.avatarItem}
|
||||
chuniSkin={userbox.avatarSkin}></ChuniPenguinComponent>
|
||||
</div>
|
||||
<div class="chuni-userbox-row">
|
||||
{#each avatarKinds as avatarKind}
|
||||
{#await DDSreader?.getFile(`avatarAccessoryThumbnail:${userbox[`avatar${avatarKind}`].toString().padStart(8, "0")}`) then imageURL}
|
||||
<button on:click={() => userboxSelected = `avatar${avatarKind}`}>
|
||||
<img src={imageURL} class={userboxSelected == `avatar${avatarKind}` ? "focused" : ""} alt={allItems.avatarAccessory[userbox[`avatar${avatarKind}`]].name} title={allItems.avatarAccessory[userbox[`avatar${avatarKind}`]].name}>
|
||||
</button>
|
||||
{/await}
|
||||
{/each}
|
||||
</div>
|
||||
<div class="chuni-userbox">
|
||||
{#if userboxSelected == "nameplateId"}
|
||||
{#each userItems.find(f => f.ubKey == "nameplateId")?.items ?? [] as item}
|
||||
{#await DDSreader?.getFile(`nameplate:${item.itemId.toString().padStart(8, "0")}`) then imageURL}
|
||||
<button class="nameplate" on:click={() => {userbox[userboxSelected] = item.itemId; submit(userboxSelected)}}>
|
||||
<img src={imageURL} alt={allItems.namePlate[item.itemId].name} title={allItems.namePlate[item.itemId].name}>
|
||||
</button>
|
||||
{/await}
|
||||
{/each}
|
||||
{:else}
|
||||
{#each userItems.find(f => f.ubKey == userboxSelected)?.items ?? [] as item}
|
||||
{#await DDSreader?.getFile(`avatarAccessoryThumbnail:${item.itemId.toString().padStart(8, "0")}`) then imageURL}
|
||||
<button on:click={() => {userbox[userboxSelected] = item.itemId; submit(userboxSelected)}}>
|
||||
<img src={imageURL} alt={allItems.avatarAccessory[item.itemId].name} title={allItems.avatarAccessory[item.itemId].name}>
|
||||
</button>
|
||||
{/await}
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="fields">
|
||||
{#each userItems.filter(i => userboxNewOptions.includes(i.iKey)) as { iKey, ubKey, items }, i}
|
||||
<div class="field">
|
||||
<label for={ubKey}>{ts(`userbox.${ubKey}`)}</label>
|
||||
<div>
|
||||
<select bind:value={userbox[ubKey]} id={ubKey} on:change={() => changed = [...changed, ubKey]}>
|
||||
{#each items as option}
|
||||
<option value={option.itemId}>{allItems[iKey][option.itemId]?.name || `(unknown ${option.itemId})`}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{#if changed.includes(ubKey)}
|
||||
<button transition:slide={{axis: "x"}} on:click={() => submit(ubKey)} disabled={!!submitting}>
|
||||
{t("settings.profile.save")}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if HAS_USERBOX_ASSETS}
|
||||
{#if USERBOX_INSTALLED}
|
||||
<!-- god this is a mess but idgaf atp -->
|
||||
<div class="field boolean" style:margin-top="1em">
|
||||
<input type="checkbox" bind:checked={USERBOX_ENABLED.value} id="newUserbox">
|
||||
<label for="newUserbox">
|
||||
<span class="name">{t("userbox.new.activate")}</span>
|
||||
<span class="desc">{t(`userbox.new.activate_desc`)}</span>
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
{#if USERBOX_SUPPORT}
|
||||
<p>
|
||||
<button on:click={() => USERBOX_SETUP_RUN = !USERBOX_SETUP_RUN}>{t(!USERBOX_INSTALLED ? `userbox.new.activate_first` : `userbox.new.activate_update`)}</button>
|
||||
</p>
|
||||
{/if}
|
||||
{#if !USERBOX_SUPPORT || !USERBOX_INSTALLED || !USERBOX_ENABLED.value}
|
||||
<h2>{t("userbox.header.preview")}</h2>
|
||||
<p class="notice">{t("userbox.preview.notice")}</p>
|
||||
<input bind:value={preview} placeholder={t("userbox.preview.url")}/>
|
||||
{#if preview}
|
||||
<div class="preview">
|
||||
{#each userItems.filter(v => v.iKey != 'trophy' && v.iKey != 'systemVoice') as { iKey, ubKey, items }, i}
|
||||
<div>
|
||||
<span>{ts(`userbox.${ubKey}`)}</span>
|
||||
<img src={`${preview}/${iKey}/${userbox[ubKey].toString().padStart(8, '0')}.png`} alt="" on:error={coverNotFound} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if USERBOX_SETUP_RUN && !error}
|
||||
<div class="overlay" transition:fade>
|
||||
<div>
|
||||
<h2>{t('userbox.new.name')}</h2>
|
||||
<span>{USERBOX_SETUP_TEXT}</span>
|
||||
<div class="actions">
|
||||
{#if USERBOX_PROGRESS != 0}
|
||||
<div class="progress">
|
||||
<div class="progress-bar" style="width: {USERBOX_PROGRESS}%"></div>
|
||||
</div>
|
||||
{:else}
|
||||
<button class="drop-btn">
|
||||
<input type="file" on:input={userboxSafeDrop} on:click={e => e.preventDefault()}>
|
||||
{t('userbox.new.drop')}
|
||||
</button>
|
||||
<button on:click={() => USERBOX_SETUP_RUN = false}>
|
||||
{t('back')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style lang="sass">
|
||||
@use "../../vars"
|
||||
|
||||
input
|
||||
width: 100%
|
||||
|
||||
|
||||
h2
|
||||
margin-bottom: 0.5rem
|
||||
|
||||
p.notice
|
||||
opacity: 0.6
|
||||
margin-top: 0
|
||||
|
||||
.progress
|
||||
width: 100%
|
||||
height: 10px
|
||||
box-shadow: 0 0 1px 1px vars.$ov-lighter
|
||||
border-radius: 25px
|
||||
margin-bottom: 15px
|
||||
overflow: hidden
|
||||
|
||||
.progress-bar
|
||||
background: #b3c6ff
|
||||
height: 100%
|
||||
border-radius: 25px
|
||||
|
||||
|
||||
.drop-btn
|
||||
position: relative
|
||||
width: 100%
|
||||
aspect-ratio: 3
|
||||
background: transparent
|
||||
box-shadow: 0 0 1px 1px vars.$ov-lighter
|
||||
margin-bottom: 1em
|
||||
|
||||
> input
|
||||
position: absolute
|
||||
top: 0
|
||||
left: 0
|
||||
width: 100%
|
||||
height: 100%
|
||||
opacity: 0
|
||||
|
||||
.preview
|
||||
margin-top: 32px
|
||||
display: flex
|
||||
flex-wrap: wrap
|
||||
justify-content: space-between
|
||||
gap: 32px
|
||||
|
||||
> div
|
||||
position: relative
|
||||
width: 100px
|
||||
height: 100px
|
||||
overflow: hidden
|
||||
background: vars.$ov-lighter
|
||||
border-radius: vars.$border-radius
|
||||
|
||||
span
|
||||
position: absolute
|
||||
bottom: 0
|
||||
width: 100%
|
||||
text-align: center
|
||||
z-index: 10
|
||||
background: rgba(0, 0, 0, 0.2)
|
||||
backdrop-filter: blur(2px)
|
||||
|
||||
img
|
||||
position: absolute
|
||||
inset: 0
|
||||
width: 100%
|
||||
height: 100%
|
||||
object-fit: contain
|
||||
|
||||
.fields
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: 12px
|
||||
width: 100%
|
||||
flex-grow: 0
|
||||
|
||||
label
|
||||
display: flex
|
||||
flex-direction: column
|
||||
|
||||
select
|
||||
width: 100%
|
||||
|
||||
.field
|
||||
display: flex
|
||||
flex-direction: column
|
||||
width: 100%
|
||||
|
||||
label
|
||||
max-width: max-content
|
||||
|
||||
> div:not(.bool)
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: 1rem
|
||||
margin-top: 0.5rem
|
||||
|
||||
> select
|
||||
flex: 1
|
||||
|
||||
|
||||
.field.boolean
|
||||
display: flex
|
||||
flex-direction: row
|
||||
align-items: center
|
||||
gap: 1rem
|
||||
width: auto
|
||||
|
||||
input
|
||||
width: auto
|
||||
aspect-ratio: 1 / 1
|
||||
|
||||
label
|
||||
display: flex
|
||||
flex-direction: column
|
||||
max-width: max-content
|
||||
|
||||
.desc
|
||||
opacity: 0.6
|
||||
|
||||
/* AquaBox */
|
||||
|
||||
.chuni-userbox-row
|
||||
width: 100%
|
||||
display: flex
|
||||
|
||||
button
|
||||
padding: 0
|
||||
margin: 0
|
||||
width: 100%
|
||||
flex: 0 1 100%
|
||||
background: none
|
||||
aspect-ratio: 1
|
||||
|
||||
img
|
||||
width: 100%
|
||||
filter: brightness(50%)
|
||||
|
||||
&.focused
|
||||
filter: brightness(75%)
|
||||
|
||||
.chuni-userbox
|
||||
width: calc(100% - 20px)
|
||||
height: 350px
|
||||
|
||||
display: flex
|
||||
flex-direction: row
|
||||
flex-wrap: wrap
|
||||
padding: 10px
|
||||
background: vars.$c-bg
|
||||
border-radius: 16px
|
||||
overflow-y: auto
|
||||
margin-bottom: 15px
|
||||
justify-content: center
|
||||
|
||||
button
|
||||
padding: 0
|
||||
margin: 0
|
||||
width: 20%
|
||||
align-self: flex-start
|
||||
background: none
|
||||
aspect-ratio: 1
|
||||
|
||||
img
|
||||
width: 100%
|
||||
|
||||
&.nameplate
|
||||
width: 50%
|
||||
aspect-ratio: unset
|
||||
border: none
|
||||
|
||||
.chuni-userbox-container
|
||||
display: flex
|
||||
align-items: center
|
||||
justify-content: center
|
||||
|
||||
@media (max-width: 1000px)
|
||||
.chuni-userbox-container
|
||||
flex-wrap: wrap
|
||||
</style>
|
||||
76
AquaNet/src/components/settings/GameSettingFields.svelte
Normal file
@@ -0,0 +1,76 @@
|
||||
<script lang="ts">
|
||||
import { slide } from "svelte/transition";
|
||||
import { SETTING } from "../../libs/sdk";
|
||||
import type { GameOption } from "../../libs/generalTypes";
|
||||
import { ts } from "../../libs/i18n";
|
||||
import StatusOverlays from "../StatusOverlays.svelte";
|
||||
import InputWithButton from "../ui/InputWithButton.svelte";
|
||||
|
||||
export let game: string;
|
||||
let gameFields: GameOption[] = []
|
||||
let submitting = ""
|
||||
let error: string;
|
||||
|
||||
SETTING.get().then(s => {
|
||||
gameFields = s.filter(it => it.game === game)
|
||||
})
|
||||
|
||||
async function submitGameOption(field: string, value: any) {
|
||||
if (submitting) return false
|
||||
submitting = field
|
||||
|
||||
await SETTING.set(field, value).catch(e => error = e.message).finally(() => submitting = "")
|
||||
return true
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="fields">
|
||||
{#each gameFields as field}
|
||||
<div class="field {field.type.toLowerCase()}">
|
||||
{#if field.type === "Boolean"}
|
||||
<input id={field.key} type="checkbox" bind:checked={field.value}
|
||||
on:change={() => submitGameOption(field.key, field.value)}/>
|
||||
<label for={field.key}>
|
||||
<span class="name">{ts(`settings.fields.${field.key}.name`)}</span>
|
||||
<span class="desc">{ts(`settings.fields.${field.key}.desc`)}</span>
|
||||
</label>
|
||||
{/if}
|
||||
{#if field.type === "String"}
|
||||
<label for={field.key}>
|
||||
<span class="name">{ts(`settings.fields.${field.key}.name`)}</span>
|
||||
<span class="desc">{ts(`settings.fields.${field.key}.desc`)}</span>
|
||||
</label>
|
||||
<InputWithButton bind:field={field} callback={() => submitGameOption(field.key, field.value)}/>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<StatusOverlays {error} loading={!gameFields.length || !!submitting}/>
|
||||
|
||||
<style lang="sass">
|
||||
.fields
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: 12px
|
||||
|
||||
.field.string
|
||||
flex-direction: column
|
||||
align-items: flex-start
|
||||
gap: 0.5rem
|
||||
|
||||
.field.boolean
|
||||
align-items: center
|
||||
gap: 1rem
|
||||
|
||||
.field
|
||||
display: flex
|
||||
|
||||
label
|
||||
display: flex
|
||||
flex-direction: column
|
||||
max-width: max-content
|
||||
|
||||
.desc
|
||||
opacity: 0.6
|
||||
</style>
|
||||
57
AquaNet/src/components/settings/GeneralGameSettings.svelte
Normal file
@@ -0,0 +1,57 @@
|
||||
<script>
|
||||
import { fade } from "svelte/transition";
|
||||
import { FADE_IN, FADE_OUT } from "../../libs/config";
|
||||
import GameSettingFields from "./GameSettingFields.svelte";
|
||||
import { ts } from "../../libs/i18n";
|
||||
import useLocalStorage from "../../libs/hooks/useLocalStorage.svelte";
|
||||
|
||||
const rounding = useLocalStorage("rounding", true);
|
||||
</script>
|
||||
|
||||
<div out:fade={FADE_OUT} in:fade={FADE_IN} class="fields">
|
||||
<GameSettingFields game="general"/>
|
||||
<div class="field">
|
||||
<div class="bool">
|
||||
<input id="rounding" type="checkbox" bind:checked={rounding.value}/>
|
||||
<label for="rounding">
|
||||
<span class="name">{ts(`settings.fields.rounding.name`)}</span>
|
||||
<span class="desc">{ts(`settings.fields.rounding.desc`)}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="sass">
|
||||
.fields
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: 12px
|
||||
|
||||
.bool
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: 1rem
|
||||
|
||||
label
|
||||
display: flex
|
||||
flex-direction: column
|
||||
|
||||
.desc
|
||||
opacity: 0.6
|
||||
|
||||
.field
|
||||
display: flex
|
||||
flex-direction: column
|
||||
|
||||
label
|
||||
max-width: max-content
|
||||
|
||||
> div:not(.bool)
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: 1rem
|
||||
margin-top: 0.5rem
|
||||
|
||||
> input
|
||||
flex: 1
|
||||
</style>
|
||||
110
AquaNet/src/components/settings/Mai2Settings.svelte
Normal file
@@ -0,0 +1,110 @@
|
||||
<script lang="ts">
|
||||
import { slide, fade } from "svelte/transition";
|
||||
import { FADE_IN, FADE_OUT } from "../../libs/config";
|
||||
import { t } from "../../libs/i18n.js";
|
||||
import Icon from "@iconify/svelte";
|
||||
import StatusOverlays from "../StatusOverlays.svelte";
|
||||
import { GAME } from "../../libs/sdk";
|
||||
|
||||
const profileFields = [
|
||||
['name', t('settings.mai2.name')],
|
||||
]
|
||||
|
||||
export let username: string;
|
||||
let error: string
|
||||
let submitting = ""
|
||||
let values = Array(profileFields.length).fill('')
|
||||
let changed: string[] = []
|
||||
|
||||
GAME.userSummary(username, 'mai2').then(({name}) => {
|
||||
values = [name]
|
||||
}).catch(e => error = e.message)
|
||||
|
||||
function submit(field: string, value: string) {
|
||||
if (submitting) return
|
||||
submitting = field
|
||||
|
||||
switch (field) {
|
||||
case 'name':
|
||||
GAME.changeName('mai2', value).then(({newName}) => {
|
||||
changed = changed.filter(c => c !== field)
|
||||
values = [newName]
|
||||
}).catch(e => error = e.message).finally(() => submitting = "")
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
function exportData() {
|
||||
submitting = "export"
|
||||
GAME.export('mai2')
|
||||
.then(data => download(JSON.stringify(data), `AquaDX_maimai2_export_${values[0]}.json`))
|
||||
.catch(e => error = e.message)
|
||||
.finally(() => submitting = "")
|
||||
}
|
||||
|
||||
function download(data: string, filename: string) {
|
||||
const blob = new Blob([data]);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.click();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="fields" out:fade={FADE_OUT} in:fade={FADE_IN}>
|
||||
{#each profileFields as [field, name], i (field)}
|
||||
<div class="field">
|
||||
<label for={field}>{name}</label>
|
||||
<div>
|
||||
<input id={field} type="text"
|
||||
bind:value={values[i]} on:input={() => changed = [...changed, field]}
|
||||
placeholder={field === 'password' ? t('settings.profile.unchanged') : t('settings.profile.unset')}/>
|
||||
{#if changed.includes(field) && values[i]}
|
||||
<button transition:slide={{axis: 'x'}} on:click={() => submit(field, values[i])}>
|
||||
{#if submitting === field}
|
||||
<Icon icon="line-md:loading-twotone-loop"/>
|
||||
{:else}
|
||||
{t('settings.profile.save')}
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
<button class="exportButton" on:click={exportData}>
|
||||
<Icon icon="bxs:file-export"/>
|
||||
{t('settings.export')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<StatusOverlays {error} loading={!values[0] || !!submitting}/>
|
||||
|
||||
<style lang="sass">
|
||||
.fields
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: 12px
|
||||
|
||||
.field
|
||||
display: flex
|
||||
flex-direction: column
|
||||
|
||||
label
|
||||
max-width: max-content
|
||||
|
||||
> div:not(.bool)
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: 1rem
|
||||
margin-top: 0.5rem
|
||||
|
||||
> input
|
||||
flex: 1
|
||||
|
||||
.exportButton
|
||||
display: flex
|
||||
justify-content: center
|
||||
align-items: center
|
||||
gap: 5px
|
||||
</style>
|
||||
9
AquaNet/src/components/settings/WaccaSettings.svelte
Normal file
@@ -0,0 +1,9 @@
|
||||
<script>
|
||||
import { fade } from "svelte/transition";
|
||||
import { FADE_IN, FADE_OUT } from "../../libs/config";
|
||||
import GameSettingFields from "./GameSettingFields.svelte";
|
||||
</script>
|
||||
|
||||
<div out:fade={FADE_OUT} in:fade={FADE_IN}>
|
||||
<GameSettingFields game="wacca"/>
|
||||
</div>
|
||||
165
AquaNet/src/components/settings/userbox/ChuniPenguin.svelte
Normal file
@@ -0,0 +1,165 @@
|
||||
<script lang="ts">
|
||||
import { DDS } from "../../../libs/userbox/dds"
|
||||
import { ddsDB } from "../../../libs/userbox/userbox"
|
||||
|
||||
const DDSreader = new DDS(ddsDB);
|
||||
|
||||
export var chuniWear = 1100001;
|
||||
export var chuniHead = 1200001;
|
||||
export var chuniFace = 1300001;
|
||||
export var chuniSkin = 1400001;
|
||||
export var chuniItem = 1500001;
|
||||
export var chuniFront = 1600001;
|
||||
export var chuniBack = 1700001;
|
||||
export var classPassthrough: string = ``
|
||||
</script>
|
||||
<div class="chuni-penguin {classPassthrough}">
|
||||
<div class="chuni-penguin-body">
|
||||
<!-- Body -->
|
||||
{#await DDSreader.getFileFromSheet(`avatarAccessory:${chuniSkin.toString().padStart(8, "0")}`, 0, 0, 256, 400, 0.75) then imageURL}
|
||||
<img class="chuni-penguin-skin" src={imageURL} alt="Body">
|
||||
{/await}
|
||||
|
||||
<!-- Face -->
|
||||
{#await DDSreader.getFileFromSheet("surfboard:CHU_UI_Common_Avatar_face_00.dds", 0, 0, 225, 150, 0.75) then imageURL}
|
||||
<img class="chuni-penguin-eyes chuni-penguin-accessory" src={imageURL} alt="Eyes">
|
||||
{/await}
|
||||
{#await DDSreader.getFileFromSheet("surfboard:CHU_UI_Common_Avatar_body_00.dds", 86, 103, 96, 43, 0.75) then imageURL}
|
||||
<img class="chuni-penguin-beak chuni-penguin-accessory" src={imageURL} alt="Beak">
|
||||
{/await}
|
||||
|
||||
<!-- Arms (surfboard) -->
|
||||
{#await DDSreader.getFileFromSheet("surfboard:CHU_UI_Common_Avatar_body_00.dds", 0, 0, 85, 160, 0.75) then imageURL}
|
||||
<img class="chuni-penguin-arm-left chuni-penguin-arm" src={imageURL} alt="Left Arm">
|
||||
{/await}
|
||||
{#await DDSreader.getFileFromSheet("surfboard:CHU_UI_Common_Avatar_body_00.dds", 0, 0, 85, 160, 0.75) then imageURL}
|
||||
<img class="chuni-penguin-arm-right chuni-penguin-arm" src={imageURL} alt="Right Arm">
|
||||
{/await}
|
||||
|
||||
<!-- Wear -->
|
||||
{#await DDSreader.getFileScaled(`avatarAccessory:${chuniWear.toString().padStart(8, "0")}`, 0.75, `avatarAccessory:01100001`) then imageURL}
|
||||
<img class="chuni-penguin-wear chuni-penguin-accessory" src={imageURL} alt="Wear">
|
||||
{/await}
|
||||
|
||||
<!-- Head -->
|
||||
{#await DDSreader.getFileScaled(`avatarAccessory:${chuniHead.toString().padStart(8, "0")}`, 0.75, `avatarAccessory:01200001`) then imageURL}
|
||||
<img class="chuni-penguin-head chuni-penguin-accessory" src={imageURL} alt="Head">
|
||||
{/await}
|
||||
{#if chuniHead == 1200001}
|
||||
<!-- If wearing original hat, add the feather and attachment -->
|
||||
{#await DDSreader.getFileFromSheet("surfboard:CHU_UI_Common_Avatar_body_00.dds", 104, 153, 57, 58, 0.75) then imageURL}
|
||||
<img class="chuni-penguin-head-2 chuni-penguin-accessory" src={imageURL} alt="Head2">
|
||||
{/await}
|
||||
{#await DDSreader.getFileFromSheet("surfboard:CHU_UI_Common_Avatar_body_00.dds", 5, 160, 100, 150, 0.75) then imageURL}
|
||||
<img class="chuni-penguin-head-3 chuni-penguin-accessory" src={imageURL} alt="Head3">
|
||||
{/await}
|
||||
{/if}
|
||||
|
||||
<!-- Face (Accessory) -->
|
||||
{#await DDSreader.getFileScaled(`avatarAccessory:${chuniFace.toString().padStart(8, "0")}`, 0.75, `avatarAccessory:01300001`) then imageURL}
|
||||
<img class="chuni-penguin-face-accessory chuni-penguin-accessory" src={imageURL} alt="Face (Accessory)">
|
||||
{/await}
|
||||
|
||||
<!-- Item -->
|
||||
{#await DDSreader.getFileScaled(`avatarAccessory:${chuniItem.toString().padStart(8, "0")}`, 0.75, `avatarAccessory:01500001`) then imageURL}
|
||||
<img class="chuni-penguin-item chuni-penguin-accessory" src={imageURL} alt="Item">
|
||||
{/await}
|
||||
|
||||
<!-- Front -->
|
||||
{#await DDSreader.getFileScaled(`avatarAccessory:${chuniFront.toString().padStart(8, "0")}`, 0.75) then imageURL}
|
||||
<img class="chuni-penguin-front chuni-penguin-accessory" src={imageURL} alt="Front">
|
||||
{/await}
|
||||
|
||||
<!-- Back -->
|
||||
{#await DDSreader.getFileScaled(`avatarAccessory:${chuniBack.toString().padStart(8, "0")}`, 0.75) then imageURL}
|
||||
<img class="chuni-penguin-back chuni-penguin-accessory" src={imageURL} alt="Back">
|
||||
{/await}
|
||||
</div>
|
||||
<div class="chuni-penguin-feet">
|
||||
<!-- Feet -->
|
||||
{#await DDSreader.getFileFromSheet(`avatarAccessory:${chuniSkin.toString().padStart(8, "0")}`, 0, 410, 167, 80, 0.75) then imageURL}
|
||||
<img src={imageURL} alt="Feet">
|
||||
{/await}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Truly sorry for the horrors below -->
|
||||
<style lang="sass">
|
||||
@keyframes chuniPenguinBodyBob
|
||||
0%
|
||||
transform: translate(-50%, 0%) translate(0%, -50%)
|
||||
50%
|
||||
transform: translate(-50%, 10px) translate(0%, -50%)
|
||||
100%
|
||||
transform: translate(-50%, 0%) translate(0%, -50%)
|
||||
@keyframes chuniPenguinArmLeft
|
||||
0%
|
||||
transform: translate(-50%, 0) rotate(-2deg)
|
||||
50%
|
||||
transform: translate(-50%, 0) rotate(2deg)
|
||||
100%
|
||||
transform: translate(-50%, 0) rotate(-2deg)
|
||||
@keyframes chuniPenguinArmRight
|
||||
0%
|
||||
transform: translate(-50%, 0) scaleX(-1) rotate(-2deg)
|
||||
50%
|
||||
transform: translate(-50%, 0) scaleX(-1) rotate(2deg)
|
||||
100%
|
||||
transform: translate(-50%, 0) scaleX(-1) rotate(-2deg)
|
||||
|
||||
img
|
||||
-webkit-user-drag: none
|
||||
|
||||
.chuni-penguin
|
||||
height: 512px
|
||||
aspect-ratio: 1/2
|
||||
position: relative
|
||||
|
||||
.chuni-penguin-body, .chuni-penguin-feet
|
||||
transform: translate(-50%, -50%)
|
||||
position: absolute
|
||||
left: 50%
|
||||
|
||||
.chuni-penguin-body
|
||||
top: 50%
|
||||
z-index: 1
|
||||
animation: chuniPenguinBodyBob 2s infinite cubic-bezier(0.45, 0, 0.55, 1)
|
||||
.chuni-penguin-feet
|
||||
top: 82.5%
|
||||
z-index: 0
|
||||
|
||||
.chuni-penguin-arm
|
||||
transform-origin: 95% 10%
|
||||
position: absolute
|
||||
top: 40%
|
||||
.chuni-penguin-arm-left
|
||||
left: 0%
|
||||
animation: chuniPenguinArmLeft 1.5s infinite cubic-bezier(0.45, 0, 0.55, 1)
|
||||
.chuni-penguin-arm-right
|
||||
left: 70%
|
||||
animation: chuniPenguinArmRight 1.5s infinite cubic-bezier(0.45, 0, 0.55, 1)
|
||||
|
||||
.chuni-penguin-accessory
|
||||
transform: translate(-50%, -50%)
|
||||
position: absolute
|
||||
top: 50%
|
||||
left: 50%
|
||||
|
||||
.chuni-penguin-eyes
|
||||
top: 22.5%
|
||||
.chuni-penguin-beak
|
||||
top: 29.5%
|
||||
.chuni-penguin-wear
|
||||
top: 57.5%
|
||||
.chuni-penguin-head
|
||||
top: 7.5%
|
||||
z-index: 10
|
||||
.chuni-penguin-head-2
|
||||
top: 12.5%
|
||||
.chuni-penguin-head-3
|
||||
top: -12.5%
|
||||
.chuni-penguin-face-accessory
|
||||
top: 27.5%
|
||||
.chuni-penguin-back
|
||||
z-index: -1
|
||||
|
||||
</style>
|
||||
137
AquaNet/src/components/settings/userbox/ChuniUserplate.svelte
Normal file
@@ -0,0 +1,137 @@
|
||||
<script lang="ts">
|
||||
import { DDS } from "../../../libs/userbox/dds"
|
||||
import { ddsDB } from "../../../libs/userbox/userbox"
|
||||
|
||||
const DDSreader = new DDS(ddsDB);
|
||||
|
||||
export var chuniLevel: number = 1
|
||||
export var chuniName: string = "AquaDX"
|
||||
export var chuniRating: number = 1.23
|
||||
export var chuniNameplate: number = 1
|
||||
export var chuniCharacter: number = 0
|
||||
export var chuniTrophyName: string = "NEWCOMER"
|
||||
</script>
|
||||
{#await DDSreader?.getFile(`nameplate:${chuniNameplate.toString().padStart(8, "0")}`) then nameplateURL}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div on:click class="chuni-nameplate" style:background={`url(${nameplateURL})`}>
|
||||
{#await DDSreader?.getFile(`characterThumbnail:${chuniCharacter.toString().padStart(6, "0")}`) then characterThumbnailURL}
|
||||
<img class="chuni-character" src={characterThumbnailURL} alt="Character">
|
||||
{/await}
|
||||
{#await DDSreader?.getFileFromSheet("surfboard:CHU_UI_title_rank_00_v10.dds", 5, 5 + (75 * 2), 595, 64) then trophyURL}
|
||||
<div class="chuni-trophy">
|
||||
{chuniTrophyName}
|
||||
<img src={trophyURL} class="chuni-trophy-bg" alt="Trophy">
|
||||
</div>
|
||||
{/await}
|
||||
<div class="chuni-user-info">
|
||||
<div class="chuni-user-name">
|
||||
<span>
|
||||
Lv.
|
||||
<span class="chuni-user-level">
|
||||
{chuniLevel}
|
||||
</span>
|
||||
</span>
|
||||
<span class="chuni-user-name-text">
|
||||
{chuniName}
|
||||
</span>
|
||||
</div>
|
||||
<div class="chuni-user-rating">
|
||||
RATING
|
||||
<span class="chuni-user-rating-number">
|
||||
{chuniRating}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/await}
|
||||
<style lang="sass">
|
||||
@use "../../../vars"
|
||||
.chuni-nameplate
|
||||
width: 576px
|
||||
height: 228px
|
||||
position: relative
|
||||
font-size: 16px
|
||||
/* Overlap penguin avatar when put side to side */
|
||||
z-index: 2
|
||||
cursor: pointer
|
||||
|
||||
.chuni-trophy
|
||||
width: 410px
|
||||
height: 45px
|
||||
background-position: center
|
||||
background-size: cover
|
||||
color: black
|
||||
|
||||
display: flex
|
||||
justify-content: center
|
||||
align-items: center
|
||||
position: absolute
|
||||
right: 25px
|
||||
top: 40px
|
||||
|
||||
font-size: 1.15em
|
||||
font-family: sans-serif
|
||||
font-weight: bold
|
||||
|
||||
z-index: 1
|
||||
text-shadow: 0 1px white
|
||||
|
||||
img
|
||||
width: 100%
|
||||
height: 100%
|
||||
position: absolute
|
||||
z-index: -1
|
||||
|
||||
.chuni-character
|
||||
position: absolute
|
||||
top: 87px
|
||||
right: 25px
|
||||
width: 82px
|
||||
aspect-ratio: 1
|
||||
box-shadow: 0 0 1px 1px white
|
||||
background: #efefef
|
||||
|
||||
.chuni-user-info
|
||||
height: 82px
|
||||
width: 320px
|
||||
position: absolute
|
||||
top: 87px
|
||||
right: 110px
|
||||
background: #fff9
|
||||
border-radius: 1px
|
||||
box-shadow: 0 0 1px 1px #ccc
|
||||
display: flex
|
||||
flex-direction: column
|
||||
|
||||
.chuni-user-name, .chuni-user-rating
|
||||
margin: 0 4px
|
||||
display: flex
|
||||
align-items: center
|
||||
color: black
|
||||
font-family: sans-serif
|
||||
font-weight: bold
|
||||
|
||||
.chuni-user-name
|
||||
flex: 1 0 65%
|
||||
box-shadow: 0 1px 0 #ccc
|
||||
|
||||
.chuni-user-level
|
||||
font-size: 2em
|
||||
margin-left: 10px
|
||||
|
||||
.chuni-user-name-text
|
||||
margin-left: auto
|
||||
font-size: 2em
|
||||
|
||||
.chuni-user-rating
|
||||
flex: 1 0 35%
|
||||
font-size: 0.875em
|
||||
text-shadow: #333 1px 1px, #333 1px -1px, #333 -1px 1px, #333 -1px -1px
|
||||
color: #ddf
|
||||
|
||||
.chuni-user-rating-number
|
||||
font-size: 1.5em
|
||||
margin-left: 10px
|
||||
|
||||
</style>
|
||||
29
AquaNet/src/components/ui/InputWithButton.svelte
Normal file
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import { slide } from "svelte/transition";
|
||||
import { ts } from "../../libs/i18n";
|
||||
|
||||
export let field: {key: string, value: string, changed?: boolean};
|
||||
export let callback: () => Promise<boolean>;
|
||||
</script>
|
||||
|
||||
<div class="field">
|
||||
<input id={field.key} type="text" bind:value={field.value}
|
||||
on:input={() => field.changed = true}/>
|
||||
{#if field.changed}
|
||||
<button on:click={async () => { if (await callback()) field.changed = false } }
|
||||
transition:slide={{axis: 'x'}}>
|
||||
{ts('settings.profile.save')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="sass">
|
||||
.field
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: 1rem
|
||||
width: 100%
|
||||
|
||||
input
|
||||
flex: 1
|
||||
</style>
|
||||
19
AquaNet/src/libs/config.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
|
||||
export const AQUA_HOST = 'https://aquadx.net/aqua'
|
||||
export const DATA_HOST = 'https://aquadx.net'
|
||||
|
||||
// This will be displayed for users to connect from the client
|
||||
export const AQUA_CONNECTION = 'aquadx.hydev.org'
|
||||
|
||||
export const TURNSTILE_SITE_KEY = '0x4AAAAAAASGA2KQEIelo9P9'
|
||||
export const DISCORD_INVITE = 'https://discord.gg/FNgveqFF7s'
|
||||
export const TELEGRAM_INVITE = 'https://t.me/+zBL4RZdyfvUzZGU1'
|
||||
export const QQ_INVITE = 'https://qm.qq.com/q/wvNXbXbHbO'
|
||||
|
||||
// UI
|
||||
export const FADE_OUT = { duration: 200 }
|
||||
export const FADE_IN = { delay: 400 }
|
||||
export const DEFAULT_PFP = '/assets/imgs/no_profile.png'
|
||||
|
||||
// USERBOX_ASSETS
|
||||
export const HAS_USERBOX_ASSETS = true
|
||||
155
AquaNet/src/libs/generalTypes.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
export interface TrendEntry {
|
||||
date: string
|
||||
rating: number
|
||||
plays: number
|
||||
}
|
||||
|
||||
export interface Card {
|
||||
luid: string
|
||||
registerTime: string
|
||||
accessTime: string
|
||||
linked: boolean
|
||||
isGhost: boolean
|
||||
}
|
||||
|
||||
export interface AquaNetUser {
|
||||
username: string
|
||||
email: string
|
||||
displayName: string
|
||||
country: string
|
||||
lastLogin: number
|
||||
regTime: number
|
||||
profileLocation: string
|
||||
profileBio: string
|
||||
profilePicture: string
|
||||
emailConfirmed: boolean
|
||||
ghostCard: Card
|
||||
cards: Card[]
|
||||
computedName: string,
|
||||
password: string,
|
||||
optOutOfLeaderboard: boolean,
|
||||
}
|
||||
|
||||
export interface CardSummaryGame {
|
||||
name: string
|
||||
rating: number
|
||||
lastLogin: string
|
||||
}
|
||||
|
||||
export interface CardSummary {
|
||||
mai2: CardSummaryGame | null
|
||||
chu3: CardSummaryGame | null
|
||||
ongeki: CardSummaryGame | null
|
||||
diva: CardSummaryGame | null
|
||||
wacca: CardSummaryGame | null
|
||||
}
|
||||
|
||||
|
||||
export interface ConfirmProps {
|
||||
title: string
|
||||
message: string
|
||||
confirm: () => void
|
||||
cancel?: () => void
|
||||
dangerous?: boolean
|
||||
}
|
||||
|
||||
export interface GenericGamePlaylog {
|
||||
musicId: number
|
||||
level: number
|
||||
playDate: string
|
||||
achievement: number
|
||||
maxCombo: number
|
||||
totalCombo: number
|
||||
afterRating: number
|
||||
beforeRating: number
|
||||
isFullCombo?: boolean
|
||||
isAllPerfect?: boolean
|
||||
isAllJustice?: boolean
|
||||
}
|
||||
|
||||
export interface GenericRanking {
|
||||
name: string
|
||||
username: string
|
||||
rank: number
|
||||
accuracy: number
|
||||
rating: number
|
||||
fullCombo: number
|
||||
allPerfect: number
|
||||
lastSeen: string
|
||||
}
|
||||
|
||||
export interface RankCount {
|
||||
name: string
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface GenericGameSummary {
|
||||
name: string
|
||||
iconId: number
|
||||
aquaUser?: AquaNetUser
|
||||
serverRank: number
|
||||
accuracy: number
|
||||
rating: number
|
||||
ratingHighest: number
|
||||
ranks: RankCount[]
|
||||
detailedRanks: { [key: number]: { [key: string]: number } }
|
||||
maxCombo: number
|
||||
fullCombo: number
|
||||
allPerfect: number
|
||||
totalScore: number
|
||||
plays: number
|
||||
totalPlayTime: number
|
||||
joined: string
|
||||
lastSeen: string
|
||||
lastVersion: string
|
||||
ratingComposition: { [key: string]: any }
|
||||
recent: GenericGamePlaylog[]
|
||||
rival?: boolean
|
||||
}
|
||||
|
||||
export interface MusicMeta {
|
||||
name: string,
|
||||
composer: string,
|
||||
bpm: number,
|
||||
ver: number,
|
||||
notes: {
|
||||
lv: number
|
||||
designer: string
|
||||
lv_id: number
|
||||
notes: number
|
||||
}[],
|
||||
worldsEndTag?: string
|
||||
worldsEndStars?: number
|
||||
}
|
||||
|
||||
export type AllMusic = { [key: string]: MusicMeta }
|
||||
|
||||
export interface GameOption {
|
||||
key: string
|
||||
value: any
|
||||
type: 'Boolean' | 'String'
|
||||
game: string
|
||||
|
||||
changed?: boolean
|
||||
}
|
||||
|
||||
export interface UserItem { itemKind: number, itemId: number, stock: number }
|
||||
export interface UserBox {
|
||||
userName: string,
|
||||
nameplateId: number,
|
||||
frameId: number,
|
||||
characterId: number,
|
||||
trophyId: number,
|
||||
mapIconId: number,
|
||||
voiceId: number,
|
||||
avatarWear: number,
|
||||
avatarHead: number,
|
||||
avatarFace: number,
|
||||
avatarSkin: number,
|
||||
avatarItem: number,
|
||||
avatarFront: number,
|
||||
avatarBack: number,
|
||||
|
||||
level: number
|
||||
playerRating: number
|
||||
}
|
||||
24
AquaNet/src/libs/hooks/useLocalStorage.svelte.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
const useLocalStorage = <T>(key: string, initialValue: T) => {
|
||||
let value = initialValue;
|
||||
|
||||
const currentValue = localStorage.getItem(key);
|
||||
if (currentValue) value = JSON.parse(currentValue);
|
||||
|
||||
const save = () => {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
};
|
||||
|
||||
return {
|
||||
get value() {
|
||||
return value;
|
||||
},
|
||||
set value(v: T) {
|
||||
value = v;
|
||||
save();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default useLocalStorage;
|
||||
52
AquaNet/src/libs/i18n.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { EN_REF, type LocalizedMessages } from "./i18n/en_ref";
|
||||
import { ZH } from "./i18n/zh";
|
||||
import type { GameName } from "./scoring";
|
||||
|
||||
type Lang = 'en' | 'zh'
|
||||
|
||||
const msgs: Record<Lang, LocalizedMessages> = {
|
||||
en: EN_REF,
|
||||
zh: ZH
|
||||
}
|
||||
|
||||
|
||||
let lang: Lang = 'en'
|
||||
|
||||
// Infer language from browser
|
||||
if (navigator.language.startsWith('zh')) {
|
||||
lang = 'zh'
|
||||
}
|
||||
|
||||
export function ts(key: string, variables?: { [index: string]: any }) {
|
||||
return t(key as keyof LocalizedMessages, variables)
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the translation for the given key
|
||||
*
|
||||
* TODO: Check for translation completion on build
|
||||
*
|
||||
* @param key
|
||||
* @param variables
|
||||
*/
|
||||
export function t(key: keyof LocalizedMessages, variables?: { [index: string]: any }) {
|
||||
// Check if the key exists
|
||||
let msg = msgs[lang][key]
|
||||
if (!msg) {
|
||||
// Check if the key exists in English
|
||||
if (!(msg = msgs.en[key])) {
|
||||
msg = key
|
||||
console.error(`ERROR!! Missing translation reference entry (English) for ${key}`)
|
||||
}
|
||||
else console.warn(`Missing translation for ${key} in ${lang}`)
|
||||
}
|
||||
// Replace variables
|
||||
if (variables) {
|
||||
return msg.replace(/\${(.*?)}/g, (_: string, v: string | number) => variables[v] + "")
|
||||
}
|
||||
return msg
|
||||
}
|
||||
Object.assign(window, { t })
|
||||
|
||||
export const GAME_TITLE: { [key in GameName]: string } =
|
||||
{chu3: t("game.chu3"), mai2: t("game.mai2"), ongeki: t("game.ongeki"), wacca: t("game.wacca")}
|
||||
198
AquaNet/src/libs/i18n/en_ref.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
export const EN_REF_USER = {
|
||||
'UserHome.ServerRank': 'Server Rank',
|
||||
'UserHome.DXRating': 'DX Rating',
|
||||
'UserHome.Rating': 'Rating',
|
||||
'UserHome.Statistics': 'Statistics',
|
||||
'UserHome.Accuracy': 'Accuracy',
|
||||
'UserHome.MaxCombo': 'Max Combo',
|
||||
'UserHome.FullCombo': 'Full Combo',
|
||||
'UserHome.AllPerfect': 'All Perfect',
|
||||
'UserHome.DXScore': 'DX Score',
|
||||
'UserHome.Score': 'Score',
|
||||
'UserHome.PlayActivity': ' Play Activity',
|
||||
'UserHome.Plays': 'Plays',
|
||||
'UserHome.PlayTime': 'Play Time',
|
||||
'UserHome.FirstSeen': 'First Seen',
|
||||
'UserHome.LastSeen': 'Last Seen',
|
||||
'UserHome.Version': 'Last Version',
|
||||
'UserHome.RecentScores': 'Recent Scores',
|
||||
'UserHome.NoData': 'No data in the past ${days} days',
|
||||
'UserHome.UnknownSong': '(unknown song)',
|
||||
'UserHome.Settings': 'Settings',
|
||||
'UserHome.NoValidGame': 'The user hasn\'t played any game yet.',
|
||||
'UserHome.ShowRanksDetails': 'Click to show details',
|
||||
'UserHome.RankDetail.Title': 'Achievement Details',
|
||||
'UserHome.RankDetail.Level': 'Level',
|
||||
'UserHome.B50': 'B50',
|
||||
'UserHome.AddRival': "Add to Rival",
|
||||
'UserHome.RemoveRival': "Remove from Rival",
|
||||
'UserHome.InvalidGame': "Game ${game} is not supported on the web UI yet. We only support maimai, chunithm, wacca, and ongeki for now.",
|
||||
}
|
||||
|
||||
export const EN_REF_Welcome = {
|
||||
'back': 'Back',
|
||||
'email': 'Email',
|
||||
'password': 'Password',
|
||||
'username': 'Username',
|
||||
'welcome.btn-login': 'Log in',
|
||||
'welcome.btn-signup': 'Sign up',
|
||||
'welcome.email-password-missing': 'Email and password are required',
|
||||
'welcome.username-missing': 'Username/email is required',
|
||||
'welcome.waiting-turnstile': 'Waiting for Turnstile to verify your network environment...',
|
||||
'welcome.turnstile-error': 'Error verifying your network environment. Please turn off your VPN and try again.',
|
||||
'welcome.turnstile-timeout': 'Network verification timed out. Please try again.',
|
||||
'welcome.verification-sent': 'A verification email has been sent to ${email}. Please check your inbox!',
|
||||
'welcome.verify-state-0': 'You haven\'t verified your email. A verification email had been sent to your inbox less than a minute ago. Please check your inbox!',
|
||||
'welcome.verify-state-1': 'You haven\'t verified your email. We\'ve already sent 3 emails over the last 24 hours so we\'ll not send another one. Please check your inbox!',
|
||||
'welcome.verify-state-2': 'You haven\'t verified your email. We just sent you another verification email. Please check your inbox!',
|
||||
'welcome.verifying': 'Verifying your email... please wait.',
|
||||
'welcome.verified': 'Your email has been verified! You can now log in now.',
|
||||
'welcome.verification-failed': 'Verification failed: ${message}. Please try again.',
|
||||
}
|
||||
|
||||
export const EN_REF_LEADERBOARD = {
|
||||
'Leaderboard.Title': 'Server Leaderboard',
|
||||
'Leaderboard.Rank': 'Rank',
|
||||
'Leaderboard.Rating': 'Rating',
|
||||
'Leaderboard.Accuracy': 'Accuracy',
|
||||
'Leaderboard.FC': 'FC',
|
||||
'Leaderboard.AP': 'AP',
|
||||
}
|
||||
|
||||
export const EN_REF_GENERAL = {
|
||||
'game.mai2': 'Mai',
|
||||
'game.chu3': 'Chuni',
|
||||
'game.ongeki': 'Ongeki',
|
||||
'game.wacca': 'Wacca',
|
||||
'status.error': 'Error',
|
||||
'status.error.hint': 'Something went wrong, please try again later or ',
|
||||
'status.error.hint.link': 'join our discord for support.',
|
||||
'status.detail': 'Detail: ${detail}',
|
||||
'action.refresh': 'Refresh',
|
||||
'action.cancel': 'Cancel',
|
||||
'action.confirm': 'Confirm',
|
||||
}
|
||||
|
||||
export const EN_REF_HOME = {
|
||||
'home.nav.portal': 'Portal',
|
||||
'home.nav.link-card': 'Link Card',
|
||||
'home.nav.game-setup': 'Game Setup',
|
||||
'home.manage-cards': 'Manage Cards',
|
||||
'home.manage-cards-description': 'Link, unlink, and manage your cards.',
|
||||
'home.link-card': 'Link Card',
|
||||
'home.link-cards-description': 'Link your Amusement IC / Aime card to play games.',
|
||||
'home.join-community': 'Join Community',
|
||||
'home.join-community-description': 'Join our community to chat with other players and get help.',
|
||||
'home.setup': 'Setup Connection',
|
||||
'home.setup-description': 'If you own a cab or arcade setup, begin setting up the connection.',
|
||||
'home.import': 'Import Player Data',
|
||||
'home.import-description': 'If you are from another server, you can import your data here.',
|
||||
'home.linkcard.cards': 'Your Cards',
|
||||
'home.linkcard.description': 'Here are the cards you have linked to your account',
|
||||
'home.linkcard.account-card': 'Account Card',
|
||||
'home.linkcard.registered': 'Registered',
|
||||
'home.linkcard.lastused': 'Last used',
|
||||
'home.linkcard.enter-info': 'Please enter the following information',
|
||||
'home.linkcard.access-code': 'The 20-digit access code on the back of your card. (If it doesn\'t work, please try scanning your card in game and enter the access code shown on screen)',
|
||||
'home.linkcard.enter-sn1': 'Download the NFC Tools app on your phone',
|
||||
'home.linkcard.enter-sn2': 'and scan your card. Then, enter the Serial Number.',
|
||||
'home.linkcard.link': 'Link',
|
||||
'home.linkcard.data-conflict': 'Data Conflict',
|
||||
'home.linkcard.name': 'Name',
|
||||
'home.linkcard.rating': 'Rating',
|
||||
'home.linkcard.last-login': 'Last Login',
|
||||
'home.linkcard.linked-own': 'This card is already linked to your account',
|
||||
'home.linkcard.linked-another': 'This card is already linked to another account',
|
||||
'home.linkcard.notfound': 'Card not found',
|
||||
'home.linkcard.unlink': 'Unlink Card',
|
||||
'home.linkcard.unlink-notice': 'Are you sure you want to unlink this card?',
|
||||
'home.setup.welcome': 'Welcome! If you own an arcade cabinet or game setup, please follow the instructions below to set up the connection with AquaDX.',
|
||||
'home.setup.blockquote': 'We assume that you already have the required files and can run the game (e.g. ROM and segatools) that come with the cabinet or game setup. If not, please contact the seller of your device for the required files, as we will not provide them for copyright reasons.',
|
||||
'home.setup.get': 'Get started',
|
||||
'home.setup.edit': 'Please edit your segatools.ini file and modify the following lines',
|
||||
'home.setup.test': 'Then, after you restart the game, you should be able to connect to AquaDX. Please verify that the network tests are all GOOD in the test menu.',
|
||||
'home.setup.ask': 'If you have any questions, please ask in our',
|
||||
'home.setup.support': 'server',
|
||||
'home.setup.keychip-tips': 'This is your unique keychip, do not share it with anyone',
|
||||
'home.import.unknown-game': 'Unknown game type. Currently only maimai and chunithm are supported for importing.',
|
||||
'home.import.new-data': 'Data to import',
|
||||
'home.import.data-conflict': 'Proceed will override your current data',
|
||||
}
|
||||
|
||||
export const EN_REF_SETTINGS = {
|
||||
'settings.title': 'Settings',
|
||||
'settings.tabs.profile': 'Profile',
|
||||
'settings.tabs.game': 'Game',
|
||||
'settings.tabs.chu3': 'Chuni',
|
||||
'settings.tabs.mai2': 'Mai',
|
||||
'settings.tabs.wacca': 'Wacca',
|
||||
'settings.fields.unlockMusic.name': 'Unlock All Music',
|
||||
'settings.fields.unlockMusic.desc': 'Unlock all music and master difficulty in game.',
|
||||
'settings.fields.unlockChara.name': 'Unlock All Characters',
|
||||
'settings.fields.unlockChara.desc': 'Unlock all characters, voices, and partners in game.',
|
||||
'settings.fields.unlockCollectables.name': 'Unlock All Collectables',
|
||||
'settings.fields.unlockCollectables.desc': 'Unlock all collectables (nameplate, title, icon, frame) in game.',
|
||||
'settings.fields.unlockTickets.name': 'Unlock All Tickets',
|
||||
'settings.fields.unlockTickets.desc': 'Infinite map/ex tickets (note: maimai still limits which tickets can be used).',
|
||||
'settings.fields.waccaInfiniteWp.name': 'Wacca: Infinite WP',
|
||||
'settings.fields.waccaInfiniteWp.desc': 'Set WP to 999999',
|
||||
'settings.fields.waccaAlwaysVip.name': 'Wacca: Always VIP',
|
||||
'settings.fields.waccaAlwaysVip.desc': 'Set VIP expiration date to 2077-01-01',
|
||||
'settings.fields.chusanTeamName.name': 'Chuni: Team Name',
|
||||
'settings.fields.chusanTeamName.desc': 'Customize the text displayed on the top of your profile.',
|
||||
'settings.fields.chusanInfinitePenguins.name': 'Chuni: Infinite Penguins',
|
||||
'settings.fields.chusanInfinitePenguins.desc': 'Set penguin statues for character level prompting to 999.',
|
||||
'settings.fields.rounding.name': 'Score Rounding',
|
||||
'settings.fields.rounding.desc': 'Round the score to one decimal place',
|
||||
'settings.fields.optOutOfLeaderboard.name': 'Opt Out of Leaderboard',
|
||||
'settings.fields.optOutOfLeaderboard.desc': 'You will still be able to see yourself on the leaderboard after logging in',
|
||||
'settings.mai2.name': 'Player Name',
|
||||
'settings.profile.picture': 'Profile Picture',
|
||||
'settings.profile.upload-new': 'Upload New',
|
||||
'settings.profile.save': 'Save',
|
||||
'settings.profile.name': 'Display Name',
|
||||
'settings.profile.username': 'Username',
|
||||
'settings.profile.password': 'Password',
|
||||
'settings.profile.location': 'Location',
|
||||
'settings.profile.bio': 'Bio',
|
||||
'settings.profile.unset': 'Unset',
|
||||
'settings.profile.unchanged': 'Unchanged',
|
||||
'settings.export': 'Export Player Data',
|
||||
}
|
||||
|
||||
export const EN_REF_USERBOX = {
|
||||
'userbox.header.general': 'General Settings',
|
||||
'userbox.header.userbox': 'UserBox Settings',
|
||||
'userbox.header.preview': 'UserBox Preview',
|
||||
'userbox.nameplateId': 'Nameplate',
|
||||
'userbox.frameId': 'Frame',
|
||||
'userbox.trophyId': 'Trophy (Title)',
|
||||
'userbox.mapIconId': 'Map Icon',
|
||||
'userbox.voiceId': 'System Voice',
|
||||
'userbox.avatarWear': 'Avatar Wear',
|
||||
'userbox.avatarHead': 'Avatar Head',
|
||||
'userbox.avatarFace': 'Avatar Face',
|
||||
'userbox.avatarSkin': 'Avatar Skin',
|
||||
'userbox.avatarItem': 'Avatar Item',
|
||||
'userbox.avatarFront': 'Avatar Front',
|
||||
'userbox.avatarBack': 'Avatar Back',
|
||||
'userbox.preview.notice': 'To honor the copyright, we cannot host the images of the userbox items. However, if someone else is willing to provide the images, you can enter their URL here and it will be displayed.',
|
||||
'userbox.preview.url': 'Image URL',
|
||||
'userbox.error.nodata': 'Chuni data not found',
|
||||
|
||||
'userbox.new.name': 'AquaBox',
|
||||
'userbox.new.setup': 'Drag and drop your Chuni game folder (Lumi or newer) into the box below to display UserBoxes with their nameplate & avatar. All files are handled in-browser.',
|
||||
'userbox.new.setup.processing_file': 'Processing',
|
||||
'userbox.new.setup.finalizing': 'Saving to internal storage',
|
||||
'userbox.new.drop': 'Drop game folder here',
|
||||
'userbox.new.activate_first': 'Enable AquaBox (game files required)',
|
||||
'userbox.new.activate_update': 'Update AquaBox (game files required)',
|
||||
'userbox.new.activate': 'Use AquaBox',
|
||||
'userbox.new.activate_desc': 'Enable displaying UserBoxes with their nameplate & avatar',
|
||||
'userbox.new.error.invalidFolder': 'The folder you selected is invalid. Ensure that your game\'s version is Lumi or newer and that the "A001" option pack is present.'
|
||||
}
|
||||
|
||||
export const EN_REF = { ...EN_REF_USER, ...EN_REF_Welcome, ...EN_REF_GENERAL,
|
||||
...EN_REF_LEADERBOARD, ...EN_REF_HOME, ...EN_REF_SETTINGS, ...EN_REF_USERBOX }
|
||||
|
||||
export type LocalizedMessages = typeof EN_REF
|
||||
206
AquaNet/src/libs/i18n/zh.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import {
|
||||
EN_REF_GENERAL,
|
||||
EN_REF_HOME,
|
||||
EN_REF_LEADERBOARD,
|
||||
EN_REF_SETTINGS,
|
||||
EN_REF_USER,
|
||||
EN_REF_USERBOX,
|
||||
type EN_REF_Welcome
|
||||
} from "./en_ref";
|
||||
|
||||
const zhUser: typeof EN_REF_USER = {
|
||||
'UserHome.ServerRank': '服务器排名',
|
||||
'UserHome.DXRating': 'DX B50',
|
||||
'UserHome.Rating': '评分',
|
||||
'UserHome.Statistics': '统计数据',
|
||||
'UserHome.Accuracy': '准确率',
|
||||
'UserHome.MaxCombo': '最大连击',
|
||||
'UserHome.FullCombo': '全连曲目',
|
||||
'UserHome.AllPerfect': '完美曲目',
|
||||
'UserHome.DXScore': 'DX 总分',
|
||||
'UserHome.Score': '总分',
|
||||
'UserHome.PlayActivity': '游戏活动',
|
||||
'UserHome.Plays': '出勤次数',
|
||||
'UserHome.PlayTime': '游玩时间',
|
||||
'UserHome.FirstSeen': '发现新大陆',
|
||||
'UserHome.LastSeen': '上次出勤',
|
||||
'UserHome.Version': '游戏版本',
|
||||
'UserHome.RecentScores': '成绩',
|
||||
'UserHome.NoData': '过去 ${days} 天内没有玩过',
|
||||
'UserHome.UnknownSong': "(未知曲目)",
|
||||
'UserHome.Settings': '设置',
|
||||
'UserHome.NoValidGame': "用户还没有玩过游戏",
|
||||
'UserHome.ShowRanksDetails': "点击显示评分详细",
|
||||
'UserHome.RankDetail.Title': '评分详细',
|
||||
'UserHome.RankDetail.Level': "等级",
|
||||
'UserHome.B50': "B50",
|
||||
'UserHome.AddRival': "添加劲敌",
|
||||
'UserHome.RemoveRival': "移除劲敌",
|
||||
'UserHome.InvalidGame': "游戏 ${game} 还不支持网页端查看。我们目前只支持舞萌、中二、Wacca 和音击。",
|
||||
}
|
||||
|
||||
const zhWelcome: typeof EN_REF_Welcome = {
|
||||
'back': '返回',
|
||||
'email': '邮箱',
|
||||
'password': '密码',
|
||||
'username': '用户名',
|
||||
'welcome.btn-login': '登录',
|
||||
'welcome.btn-signup': '注册',
|
||||
'welcome.email-password-missing': '邮箱和密码必须填哦',
|
||||
'welcome.username-missing': '用户名/邮箱必须填哦',
|
||||
'welcome.waiting-turnstile': '正在验证网络环境...',
|
||||
'welcome.turnstile-error': '验证网络环境出错了,请关闭VPN后重试',
|
||||
'welcome.turnstile-timeout': '验证网络环境超时了,请重试',
|
||||
'welcome.verification-sent': '验证邮件已发送至 ${email},请翻翻收件箱',
|
||||
'welcome.verify-state-0': '您还没有验证邮箱哦!验证邮件一分钟内刚刚发到您的邮箱,请翻翻收件箱',
|
||||
'welcome.verify-state-1': '您还没有验证邮箱哦!我们在过去的24小时内已经发送了3封验证邮件,所以我们不会再发送了,请翻翻收件箱',
|
||||
'welcome.verify-state-2': '您还没有验证邮箱哦!我们刚刚又发送了一封验证邮件,请翻翻收件箱',
|
||||
'welcome.verifying': '正在验证邮箱...请稍等',
|
||||
'welcome.verified': '您的邮箱已经验证成功!您现在可以登录了',
|
||||
'welcome.verification-failed': '验证失败:${message}。请重试',
|
||||
}
|
||||
|
||||
const zhLeaderboard: typeof EN_REF_LEADERBOARD = {
|
||||
'Leaderboard.Title': '排行榜',
|
||||
'Leaderboard.Rank': '排名',
|
||||
'Leaderboard.Rating': '评分',
|
||||
'Leaderboard.Accuracy': '准确率',
|
||||
'Leaderboard.FC': 'FC',
|
||||
'Leaderboard.AP': 'AP',
|
||||
}
|
||||
|
||||
const zhGeneral: typeof EN_REF_GENERAL = {
|
||||
'game.mai2': "舞萌",
|
||||
'game.chu3': "中二",
|
||||
'game.ongeki': "音击",
|
||||
'game.wacca': "Wacca",
|
||||
"status.error": "发生错误",
|
||||
"status.error.hint": "出了一些问题,请稍后刷新重试或者",
|
||||
"status.error.hint.link": "加我们的 Discord 群问一问",
|
||||
"status.detail": "详细信息:${detail}",
|
||||
"action.refresh": "刷新",
|
||||
"action.cancel": "取消",
|
||||
"action.confirm": "确认",
|
||||
}
|
||||
|
||||
const zhHome: typeof EN_REF_HOME = {
|
||||
'home.nav.portal': "主页",
|
||||
'home.nav.link-card': "绑卡",
|
||||
'home.nav.game-setup': "连接设置",
|
||||
'home.manage-cards': '管理游戏卡',
|
||||
'home.manage-cards-description': '绑定、解绑、管理游戏数据卡',
|
||||
'home.link-card': '绑定游戏卡',
|
||||
'home.link-cards-description':'绑定游戏数据卡 (Amusement IC 或 Aime 卡) 后才可以访问游戏存档哦',
|
||||
'home.join-community': '加入群组',
|
||||
'home.join-community-description': '加入我们的聊天群组,与其他玩家聊天、获取帮助',
|
||||
'home.setup': '连接到 AquaDX',
|
||||
'home.setup-description': '如果您有街机框体或者手台,点击这里设置服务器的连接',
|
||||
'home.import': '导入玩家数据',
|
||||
'home.import-description': '如果你来自其他在线服,可以点击这里导入从其他服务器导出的数据',
|
||||
'home.linkcard.cards': "已绑卡片",
|
||||
'home.linkcard.description': "这些是您绑定到帐户的卡",
|
||||
'home.linkcard.account-card': "账户卡",
|
||||
'home.linkcard.registered': "注册于",
|
||||
'home.linkcard.lastused': "上次使用",
|
||||
'home.linkcard.enter-info': "请输入以下信息",
|
||||
'home.linkcard.access-code': "卡背面的20位卡号 (如果没有, 请尝试在游戏中扫描您的卡, 并输入屏幕上显示的卡号)",
|
||||
'home.linkcard.enter-sn1': "在您的手机",
|
||||
'home.linkcard.enter-sn2': "上下载 NFC Tools 并扫描您的卡。然后输入显示的 SN 号。",
|
||||
'home.linkcard.link': "绑定",
|
||||
'home.linkcard.data-conflict': "卡号冲突",
|
||||
'home.linkcard.name': "名称",
|
||||
'home.linkcard.rating': "Rating",
|
||||
'home.linkcard.last-login': "上次登录",
|
||||
'home.linkcard.linked-own': "此卡已链接到您的帐户",
|
||||
'home.linkcard.linked-another': "此卡已链接到其他用户",
|
||||
'home.linkcard.notfound': "找不到卡",
|
||||
'home.linkcard.unlink': "取消链接",
|
||||
'home.linkcard.unlink-notice': "你确定要取消此卡的链接吗?",
|
||||
'home.setup.welcome': "欢迎! 如果您有街机框体或者手台, 请按照以下说明设置以连接到 AquaDX.",
|
||||
'home.setup.blockquote': "我们假设您已经拥有所需的文件, 并且可以启动机台或手台附带的游戏 (例如 ROM 和 segatools )。如果没有, 请联系您设备的卖家以获取所需的文件, 因为出于版权原因, 我们不会提供这些文件。",
|
||||
'home.setup.get': "开始",
|
||||
'home.setup.edit': "请打开您的 segatools.ini 文件并修改以下行",
|
||||
'home.setup.test': "在您重新启动游戏后, 应该能够连接到 AquaDX。可以验证测试菜单中的网络测试测试连接是否全部良好。",
|
||||
'home.setup.ask': "如果您有任何问题, 请加入我们的",
|
||||
'home.setup.support': "以获取支持",
|
||||
'home.setup.keychip-tips': "这是你的狗号, 不要与任何人分享",
|
||||
'home.import.unknown-game': '未知游戏类型 (目前导入只支持舞萌和中二)',
|
||||
'home.import.new-data': '要导入的数据',
|
||||
'home.import.data-conflict': '继续导入将覆盖现有数据',
|
||||
}
|
||||
|
||||
const zhSettings: typeof EN_REF_SETTINGS = {
|
||||
'settings.title': '用户设置',
|
||||
'settings.tabs.profile': '个人资料',
|
||||
'settings.tabs.game': '游戏设置',
|
||||
'settings.tabs.chu3': '中二',
|
||||
'settings.tabs.mai2': '舞萌',
|
||||
'settings.tabs.wacca': 'Wacca',
|
||||
'settings.fields.unlockMusic.name': '解锁谱面',
|
||||
'settings.fields.unlockMusic.desc': '在游戏中解锁所有曲目和大师难度谱面。',
|
||||
'settings.fields.unlockChara.name': '解锁角色',
|
||||
'settings.fields.unlockChara.desc': '在游戏中解锁所有角色、语音和伙伴。',
|
||||
'settings.fields.unlockCollectables.name': '解锁收藏品',
|
||||
'settings.fields.unlockCollectables.desc': '在游戏中解锁所有收藏品(名牌、称号、图标、背景图)。',
|
||||
'settings.fields.unlockTickets.name': '解锁游戏券',
|
||||
'settings.fields.unlockTickets.desc': '无限跑图券/解锁券(注:maimai 客户端仍限制一些券不能使用)。',
|
||||
'settings.fields.waccaInfiniteWp.name': 'Wacca: 无限 WP',
|
||||
'settings.fields.waccaInfiniteWp.desc': '将 WP 设置为 999999',
|
||||
'settings.fields.waccaAlwaysVip.name': 'Wacca: 永久会员',
|
||||
'settings.fields.waccaAlwaysVip.desc': '将 VIP 到期时间设置为 2077-01-01',
|
||||
'settings.fields.chusanTeamName.name': '中二: 队伍名称',
|
||||
'settings.fields.chusanTeamName.desc': '自定义显示在个人资料顶部的文本。',
|
||||
'settings.fields.chusanInfinitePenguins.name': '中二: 无限企鹅',
|
||||
'settings.fields.chusanInfinitePenguins.desc': '将角色界限突破的企鹅雕像数量设置为 999。',
|
||||
'settings.fields.rounding.name': '分数舍入',
|
||||
'settings.fields.rounding.desc': '把分数四舍五入到一位小数',
|
||||
'settings.fields.optOutOfLeaderboard.name': '不参与排行榜',
|
||||
'settings.fields.optOutOfLeaderboard.desc': '登录之后还是可以在排行榜上看到自己',
|
||||
'settings.mai2.name': '玩家名字',
|
||||
'settings.profile.picture': '头像',
|
||||
'settings.profile.upload-new': '上传',
|
||||
'settings.profile.save': '保存',
|
||||
'settings.profile.name': '昵称',
|
||||
'settings.profile.username': '用户名',
|
||||
'settings.profile.password': '密码',
|
||||
'settings.profile.location': '位置',
|
||||
'settings.profile.bio': '简介',
|
||||
'settings.profile.unset': '未设置',
|
||||
'settings.profile.unchanged': '未更改',
|
||||
'settings.export': '导出玩家数据',
|
||||
}
|
||||
|
||||
export const zhUserbox: typeof EN_REF_USERBOX = {
|
||||
'userbox.header.general': '游戏设置',
|
||||
'userbox.header.userbox': 'UserBox 设置',
|
||||
'userbox.header.preview': 'UserBox 预览',
|
||||
'userbox.nameplateId': '名牌',
|
||||
'userbox.frameId': '边框',
|
||||
'userbox.trophyId': '称号',
|
||||
'userbox.mapIconId': '地图图标',
|
||||
'userbox.voiceId': '系统语音',
|
||||
'userbox.avatarWear': '企鹅服饰',
|
||||
'userbox.avatarHead': '企鹅头饰',
|
||||
'userbox.avatarFace': '企鹅面部',
|
||||
'userbox.avatarSkin': '企鹅皮肤',
|
||||
'userbox.avatarItem': '企鹅物品',
|
||||
'userbox.avatarFront': '企鹅前景',
|
||||
'userbox.avatarBack': '企鹅背景',
|
||||
'userbox.preview.notice': '「生存战略」:为了尊重版权,我们不会提供游戏内物品的图片。但是如果你认识其他愿意提供图床的人,在这里输入 URL 就可以显示出预览。',
|
||||
'userbox.preview.url': '图床 URL',
|
||||
'userbox.error.nodata': '未找到中二数据',
|
||||
|
||||
'userbox.new.name': 'AquaBox',
|
||||
'userbox.new.setup': '将 Chuni(Lumi 或更高版本)的游戏文件夹拖放到下方区域,以显示带有名牌和头像的 UserBox。所有文件都在浏览器中处理。',
|
||||
'userbox.new.setup.processing_file': '正在处理文件',
|
||||
'userbox.new.setup.finalizing': '正在保存到内部存储',
|
||||
'userbox.new.drop': '将游戏文件夹拖到此处',
|
||||
'userbox.new.activate_first': '启用 AquaBox(需要游戏文件)',
|
||||
'userbox.new.activate_update': '更新 AquaBox(需要游戏文件)',
|
||||
'userbox.new.activate': '使用 AquaBox',
|
||||
'userbox.new.activate_desc': '启用后可显示带有名牌和头像的 UserBox',
|
||||
'userbox.new.error.invalidFolder': '所选文件夹无效。请确认游戏版本为 Lumi 或更新,并且包含 “A001” 选项包。'
|
||||
};
|
||||
|
||||
export const ZH = { ...zhUser, ...zhWelcome, ...zhGeneral,
|
||||
...zhLeaderboard, ...zhHome, ...zhSettings, ...zhUserbox }
|
||||
13
AquaNet/src/libs/maimai.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { AQUA_HOST, DATA_HOST } from './config'
|
||||
|
||||
|
||||
export async function getMaimai(endpoint: string, params: any) {
|
||||
return await fetch(`${AQUA_HOST}/Maimai2Servlet/${endpoint}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(params)
|
||||
}).then(res => res.json())
|
||||
}
|
||||
|
||||
export async function getMaimaiAllMusic(): Promise<{ [key: string]: any }> {
|
||||
return fetch(`${DATA_HOST}/maimai/meta/00/all-music.json`).then(it => it.json())
|
||||
}
|
||||
107
AquaNet/src/libs/maimaiTypes.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import type { MusicMeta } from "./generalTypes";
|
||||
|
||||
export interface Rating {
|
||||
musicId: number
|
||||
level: number
|
||||
achievement: number
|
||||
}
|
||||
|
||||
export interface ParsedRating extends Rating {
|
||||
music: MusicMeta,
|
||||
calc: number,
|
||||
rank: string
|
||||
}
|
||||
|
||||
export interface MaimaiUserSummaryEntry {
|
||||
name: string
|
||||
iconId: number
|
||||
serverRank: number
|
||||
accuracy: number
|
||||
rating: number
|
||||
ratingHighest: number
|
||||
ranks: { name: string, count: number }[]
|
||||
maxCombo: number
|
||||
fullCombo: number
|
||||
allPerfect: number
|
||||
totalDxScore: number
|
||||
plays: number
|
||||
totalPlayTime: number
|
||||
joined: string
|
||||
lastSeen: string
|
||||
lastVersion: string
|
||||
best35: string
|
||||
best15: string
|
||||
recent: MaimaiUserPlaylog[]
|
||||
}
|
||||
|
||||
export interface MaimaiUserPlaylog {
|
||||
id: number;
|
||||
musicId: number;
|
||||
level: number;
|
||||
userPlayDate: string;
|
||||
trackNo: number;
|
||||
vsRank: number;
|
||||
achievement: number;
|
||||
deluxscore: number;
|
||||
scoreRank: number;
|
||||
maxCombo: number;
|
||||
totalCombo: number;
|
||||
maxSync: number;
|
||||
totalSync: number;
|
||||
tapCriticalPerfect: number;
|
||||
tapPerfect: number;
|
||||
tapGreat: number;
|
||||
tapGood: number;
|
||||
tapMiss: number;
|
||||
holdCriticalPerfect: number;
|
||||
holdPerfect: number;
|
||||
holdGreat: number;
|
||||
holdGood: number;
|
||||
holdMiss: number;
|
||||
slideCriticalPerfect: number;
|
||||
slidePerfect: number;
|
||||
slideGreat: number;
|
||||
slideGood: number;
|
||||
slideMiss: number;
|
||||
touchCriticalPerfect: number;
|
||||
touchPerfect: number;
|
||||
touchGreat: number;
|
||||
touchGood: number;
|
||||
touchMiss: number;
|
||||
breakCriticalPerfect: number;
|
||||
breakPerfect: number;
|
||||
breakGreat: number;
|
||||
breakGood: number;
|
||||
breakMiss: number;
|
||||
isTap: boolean;
|
||||
isHold: boolean;
|
||||
isSlide: boolean;
|
||||
isTouch: boolean;
|
||||
isBreak: boolean;
|
||||
isCriticalDisp: boolean;
|
||||
isFastLateDisp: boolean;
|
||||
fastCount: number;
|
||||
lateCount: number;
|
||||
isAchieveNewRecord: boolean;
|
||||
isDeluxscoreNewRecord: boolean;
|
||||
comboStatus: number;
|
||||
syncStatus: number;
|
||||
isClear: boolean;
|
||||
beforeRating: number;
|
||||
afterRating: number;
|
||||
beforeGrade: number;
|
||||
afterGrade: number;
|
||||
afterGradeRank: number;
|
||||
beforeDeluxRating: number;
|
||||
afterDeluxRating: number;
|
||||
isPlayTutorial: boolean;
|
||||
isEventMode: boolean;
|
||||
isFreedomMode: boolean;
|
||||
playMode: number;
|
||||
isNewFree: boolean;
|
||||
trialPlayAchievement: number;
|
||||
extNum1: number;
|
||||
extNum2: number;
|
||||
extNum4: number;
|
||||
extBool1: boolean;
|
||||
}
|
||||
24
AquaNet/src/libs/ongekiTypes.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { GenericGamePlaylog } from "./generalTypes";
|
||||
|
||||
export interface OngekiUserSummaryEntry {
|
||||
name: string
|
||||
iconId: number
|
||||
serverRank: number
|
||||
accuracy: number
|
||||
rating: number
|
||||
ratingHighest: number
|
||||
ranks: { name: string, count: number }[]
|
||||
maxCombo: number
|
||||
fullCombo: number
|
||||
allPerfect: number
|
||||
totalDxScore: number
|
||||
plays: number
|
||||
totalPlayTime: number
|
||||
joined: string
|
||||
lastSeen: string
|
||||
lastVersion: string
|
||||
best30: string
|
||||
best15: string
|
||||
recent10: string
|
||||
recent: GenericGamePlaylog[]
|
||||
}
|
||||
153
AquaNet/src/libs/scoring.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { DATA_HOST } from "./config"
|
||||
import type { MusicMeta } from "./generalTypes"
|
||||
|
||||
export type GameName = 'mai2' | 'chu3' | 'ongeki' | 'wacca'
|
||||
|
||||
const multTable = {
|
||||
'mai2': [
|
||||
[ 100.5, 22.4, 'SSSp' ],
|
||||
[ 100.0, 21.6, 'SSS' ],
|
||||
[ 99.5, 21.1, 'SSp' ],
|
||||
[ 99, 20.8, 'SS' ],
|
||||
[ 98, 20.3, 'Sp' ],
|
||||
[ 97, 20, 'S' ],
|
||||
[ 94, 16.8, 'AAA' ],
|
||||
[ 90, 15.2, 'AA' ],
|
||||
[ 80, 13.6, 'A' ],
|
||||
[ 75, 12, 'BBB' ],
|
||||
[ 70, 11.2, 'BB' ],
|
||||
[ 60, 9.6, 'B' ],
|
||||
[ 50, 8, 'C' ],
|
||||
[ 40, 6.4, 'D' ],
|
||||
[ 30, 4.8, 'D' ],
|
||||
[ 20, 3.2, 'D' ],
|
||||
[ 10, 1.6, 'D' ],
|
||||
[ 0, 0, 'D' ]
|
||||
],
|
||||
|
||||
// TODO: Fill in multipliers for Chunithm and Ongeki
|
||||
'chu3': [
|
||||
[ 100.9, 215, 'SSS+' ],
|
||||
[ 100.75, 200, 'SSS' ],
|
||||
[ 100.0, 0, 'SS' ],
|
||||
[ 97.5, 0, 'S' ],
|
||||
[ 95.0, 0, 'AAA' ],
|
||||
[ 92.5, 0, 'AA' ],
|
||||
[ 90.0, 0, 'A' ],
|
||||
[ 80.0, 0, 'BBB' ],
|
||||
[ 70.0, 0, 'BB' ],
|
||||
[ 60.0, 0, 'B' ],
|
||||
[ 50.0, 0, 'C' ],
|
||||
[ 0.0, 0, 'D' ]
|
||||
],
|
||||
|
||||
'ongeki': [
|
||||
[ 100.75, 0, 'SSS+' ],
|
||||
[ 100.0, 0, 'SSS' ],
|
||||
[ 99.0, 0, 'SS' ],
|
||||
[ 97.0, 0, 'S' ],
|
||||
[ 94.0, 0, 'AAA' ],
|
||||
[ 90.0, 0, 'AA' ],
|
||||
[ 85.0, 0, 'A' ],
|
||||
[ 80.0, 0, 'BBB' ],
|
||||
[ 75.0, 0, 'BB' ],
|
||||
[ 70.0, 0, 'B' ],
|
||||
[ 50.0, 0, 'C' ],
|
||||
[ 0.0, 0, 'D' ]
|
||||
],
|
||||
|
||||
'wacca': [
|
||||
[ 100.0, 0, 'AP' ],
|
||||
[ 98.0, 0, 'SSS' ],
|
||||
[ 95.0, 0, 'SS' ],
|
||||
[ 90.0, 0, 'S' ],
|
||||
[ 85.0, 0, 'AAA' ],
|
||||
[ 80.0, 0, 'AA' ],
|
||||
[ 70.0, 0, 'A' ],
|
||||
[ 60.0, 0, 'B' ],
|
||||
[ 1.0, 0, 'C' ],
|
||||
[ 0.0, 0, 'D' ]
|
||||
]
|
||||
}
|
||||
|
||||
export function getMult(achievement: number, game: GameName) {
|
||||
achievement /= 10000
|
||||
const mt = multTable[game]
|
||||
for (let i = 0; i < mt.length; i++) {
|
||||
if (achievement >= (mt[i][0] as number)) return mt[i]
|
||||
}
|
||||
return [ 0, 0, 0 ]
|
||||
}
|
||||
|
||||
export function roundFloor(achievement: number, game: GameName, digits = 2) {
|
||||
// Round, but if the rounded number reaches the next rank, use floor instead
|
||||
const mult = getMult(achievement, game);
|
||||
achievement /= 10000
|
||||
const rounded = achievement.toFixed(digits);
|
||||
if (getMult(+rounded * 10000, game)[2] === mult[2] && rounded !== '101.0') return rounded;
|
||||
return (+rounded - Math.pow(10, -digits)).toFixed(digits);
|
||||
}
|
||||
|
||||
export function chusanRating(lv: number, score: number) {
|
||||
lv = lv * 100
|
||||
if (score >= 1009000) return lv + 215; // SSS+
|
||||
if (score >= 1007500) return lv + 200 + (score - 1007500) / 100; // SSS
|
||||
if (score >= 1005000) return lv + 150 + (score - 1005000) / 50; // SS+
|
||||
if (score >= 1000000) return lv + 100 + (score - 1000000) / 100; // SS
|
||||
if (score >= 975000) return lv + (score - 975000) / 250; // S+, S
|
||||
if (score >= 925000) return lv - 300 + (score - 925000) * 3 / 500; // AA
|
||||
if (score >= 900000) return lv - 500 + (score - 900000) * 4 / 500; // A
|
||||
if (score >= 800000) return ((lv - 500) / 2 + (score - 800000) * ((lv - 500) / 2) / (100000)); // BBB
|
||||
return 0; // C
|
||||
}
|
||||
|
||||
export interface ParsedComposition {
|
||||
name?: string
|
||||
musicId: number
|
||||
diffId: number // ID of the difficulty
|
||||
score: number
|
||||
cutoff: number
|
||||
mult: number
|
||||
rank: string // e.g. 'SSS+'
|
||||
difficulty?: number // Actual difficulty of the map
|
||||
img: string
|
||||
ratingChange?: string // Rating change after playing this map
|
||||
}
|
||||
|
||||
|
||||
export function parseComposition(item: string, allMusics: Record<string, MusicMeta>, game: GameName): ParsedComposition {
|
||||
// Chuni & ongeki: musicId, difficultId, score
|
||||
// Mai: musicId, level (difficultyId), romVersion, achievement (score)
|
||||
const mapData = item.split(':').map(Number)
|
||||
if (game === 'mai2') mapData.splice(2, 1)
|
||||
const [ musicId, diffId, score ] = mapData
|
||||
const meta = allMusics[musicId]
|
||||
|
||||
// Get score multiplier
|
||||
const tup = getMult(score, game)
|
||||
const [ cutoff, mult ] = [ +tup[0], +tup[1] ]
|
||||
const rank = "" + tup[2]
|
||||
|
||||
let diff = meta?.notes?.[diffId === 10 ? 0 : diffId]?.lv
|
||||
|
||||
function calcDxChange() {
|
||||
if (!diff) return
|
||||
if (game === 'mai2')
|
||||
return Math.floor(diff * mult * (Math.min(100.5, score / 10000) / 100)).toFixed(0)
|
||||
if (game === 'chu3')
|
||||
return (chusanRating(diff, score) / 100).toFixed(1)
|
||||
}
|
||||
|
||||
return {
|
||||
name: meta?.name,
|
||||
musicId,
|
||||
diffId,
|
||||
score,
|
||||
cutoff,
|
||||
mult,
|
||||
rank,
|
||||
difficulty: diff,
|
||||
img: `${DATA_HOST}/d/${game}/music/00${mapData[0].toString().padStart(6, '0').substring(2)}.png`,
|
||||
ratingChange: calcDxChange()
|
||||
}
|
||||
}
|
||||
313
AquaNet/src/libs/sdk.ts
Normal file
@@ -0,0 +1,313 @@
|
||||
import { AQUA_HOST, DATA_HOST } from './config'
|
||||
import type {
|
||||
AllMusic,
|
||||
Card,
|
||||
CardSummary,
|
||||
GenericGameSummary,
|
||||
GenericRanking,
|
||||
TrendEntry,
|
||||
AquaNetUser, GameOption,
|
||||
UserBox,
|
||||
UserItem
|
||||
} from './generalTypes'
|
||||
import type { GameName } from './scoring'
|
||||
|
||||
interface RequestInitWithParams extends RequestInit {
|
||||
params?: { [index: string]: string }
|
||||
localCache?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Modify a fetch url
|
||||
*
|
||||
* @param input Fetch url input
|
||||
* @param callback Callback for modification
|
||||
*/
|
||||
export function reconstructUrl(input: URL | RequestInfo, callback: (url: URL) => URL | void): RequestInfo | URL {
|
||||
let u = new URL((input instanceof Request) ? input.url : input)
|
||||
const result = callback(u)
|
||||
if (result) u = result
|
||||
if (input instanceof Request) {
|
||||
// @ts-ignore
|
||||
return { url: u, ...input }
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch with url parameters
|
||||
*/
|
||||
export function fetchWithParams(input: URL | RequestInfo, init?: RequestInitWithParams): Promise<Response> {
|
||||
return fetch(reconstructUrl(input, u => {
|
||||
u.search = new URLSearchParams(init?.params ?? {}).toString()
|
||||
}), init)
|
||||
}
|
||||
|
||||
const cache: { [index: string]: any } = {}
|
||||
|
||||
export async function post(endpoint: string, params: Record<string, any> = {}, init?: RequestInitWithParams): Promise<any> {
|
||||
// Add token if exists
|
||||
const token = localStorage.getItem('token')
|
||||
if (token && !('token' in params)) params = { ...(params ?? {}), token }
|
||||
|
||||
if (init?.localCache) {
|
||||
const cached = cache[endpoint + JSON.stringify(params) + JSON.stringify(init)]
|
||||
if (cached) return cached
|
||||
}
|
||||
|
||||
const res = await fetchWithParams(AQUA_HOST + endpoint, {
|
||||
method: 'POST',
|
||||
params,
|
||||
...init
|
||||
}).catch(e => {
|
||||
console.error(e)
|
||||
throw new Error('Network error')
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text()
|
||||
console.error(`${res.status}: ${text}`)
|
||||
|
||||
// If 400 invalid token is caught, should invalidate the token and redirect to signin
|
||||
if (text === 'Invalid token') {
|
||||
localStorage.removeItem('token')
|
||||
window.location.href = '/'
|
||||
}
|
||||
|
||||
// Try to parse as json
|
||||
let json
|
||||
try {
|
||||
json = JSON.parse(text)
|
||||
} catch (e) {
|
||||
throw new Error(text)
|
||||
}
|
||||
if (json.error) throw new Error(json.error)
|
||||
}
|
||||
|
||||
const ret = res.json()
|
||||
cache[endpoint + JSON.stringify(params) + JSON.stringify(init)] = ret
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
export async function get(endpoint: string, params:any,init?: RequestInitWithParams): Promise<any> {
|
||||
// Add token if exists
|
||||
const token = localStorage.getItem('token')
|
||||
if (token && !('token' in params)) params = { ...(params ?? {}), token }
|
||||
|
||||
if (init?.localCache) {
|
||||
const cached = cache[endpoint + JSON.stringify(init)]
|
||||
if (cached) return cached
|
||||
}
|
||||
|
||||
const res = await fetchWithParams(AQUA_HOST + endpoint, {
|
||||
method: 'GET',
|
||||
params,
|
||||
...init
|
||||
}).catch(e => {
|
||||
console.error(e)
|
||||
throw new Error('Network error')
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text()
|
||||
console.error(`${res.status}: ${text}`)
|
||||
|
||||
// If 400 invalid token is caught, should invalidate the token and redirect to signin
|
||||
if (text === 'Invalid token') {
|
||||
localStorage.removeItem('token')
|
||||
window.location.href = '/'
|
||||
}
|
||||
|
||||
// Try to parse as json
|
||||
let json
|
||||
try {
|
||||
json = JSON.parse(text)
|
||||
} catch (e) {
|
||||
throw new Error(text)
|
||||
}
|
||||
if (json.error) throw new Error(json.error)
|
||||
}
|
||||
|
||||
const ret = res.json()
|
||||
cache[endpoint + JSON.stringify(init)] = ret
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
export async function put(endpoint: string, params: any, init?: RequestInitWithParams): Promise<any> {
|
||||
// Add token if exists
|
||||
const token = localStorage.getItem('token')
|
||||
if (token && !('token' in params)) params = { ...(params ?? {}), token }
|
||||
|
||||
if (init?.localCache) {
|
||||
const cached = cache[endpoint + JSON.stringify(params) + JSON.stringify(init)]
|
||||
if (cached) return cached
|
||||
}
|
||||
|
||||
const res = await fetchWithParams(AQUA_HOST + endpoint, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(params),
|
||||
headers:{
|
||||
'Content-Type':'application/json',
|
||||
...init?.headers
|
||||
},
|
||||
...init
|
||||
}).catch(e => {
|
||||
console.error(e)
|
||||
throw new Error('Network error')
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text()
|
||||
console.error(`${res.status}: ${text}`)
|
||||
|
||||
// If 400 invalid token is caught, should invalidate the token and redirect to signin
|
||||
if (text === 'Invalid token') {
|
||||
localStorage.removeItem('token')
|
||||
window.location.href = '/'
|
||||
}
|
||||
|
||||
// Try to parse as json
|
||||
let json
|
||||
try {
|
||||
json = JSON.parse(text)
|
||||
} catch (e) {
|
||||
throw new Error(text)
|
||||
}
|
||||
if (json.error) throw new Error(json.error)
|
||||
}
|
||||
|
||||
const ret = res.json()
|
||||
cache[endpoint + JSON.stringify(params) + JSON.stringify(init)] = ret
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
export async function realPost(endpoint: string, params: any, init?: RequestInitWithParams): Promise<any> {
|
||||
const res = await fetchWithParams(AQUA_HOST + endpoint, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(params),
|
||||
headers:{
|
||||
'Content-Type':'application/json',
|
||||
...init?.headers
|
||||
},
|
||||
...init
|
||||
}).catch(e => {
|
||||
console.error(e)
|
||||
throw new Error('Network error')
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text()
|
||||
console.error(`${res.status}: ${text}`)
|
||||
|
||||
// If 400 invalid token is caught, should invalidate the token and redirect to signin
|
||||
if (text === 'Invalid token') {
|
||||
localStorage.removeItem('token')
|
||||
window.location.href = '/'
|
||||
}
|
||||
|
||||
// Try to parse as json
|
||||
let json
|
||||
try {
|
||||
json = JSON.parse(text)
|
||||
} catch (e) {
|
||||
throw new Error(text)
|
||||
}
|
||||
if (json.error) throw new Error(json.error)
|
||||
}
|
||||
|
||||
return res.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* aqua.net.UserRegistrar
|
||||
*
|
||||
* @param user
|
||||
*/
|
||||
async function register(user: { username: string, email: string, password: string, turnstile: string }) {
|
||||
return await post('/api/v2/user/register', user)
|
||||
}
|
||||
async function login(user: { email: string, password: string, turnstile: string }) {
|
||||
const data = await post('/api/v2/user/login', user)
|
||||
|
||||
// Put token into local storage
|
||||
localStorage.setItem('token', data.token)
|
||||
}
|
||||
|
||||
const isLoggedIn = () => !!localStorage.getItem('token')
|
||||
const ensureLoggedIn = () => !isLoggedIn() && (window.location.href = '/')
|
||||
|
||||
export const USER = {
|
||||
register,
|
||||
login,
|
||||
confirmEmail: (token: string) =>
|
||||
post('/api/v2/user/confirm-email', { token }),
|
||||
me: (): Promise<AquaNetUser> => {
|
||||
ensureLoggedIn()
|
||||
return post('/api/v2/user/me', {})
|
||||
},
|
||||
keychip: (): Promise<string> =>
|
||||
post('/api/v2/user/keychip', {}).then(it => it.keychip),
|
||||
setting: (key: string, value: string) =>
|
||||
post('/api/v2/user/setting', { key: key === 'password' ? 'pwHash' : key, value }),
|
||||
uploadPfp: (file: File) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
return post('/api/v2/user/upload-pfp', { }, { method: 'POST', body: formData })
|
||||
},
|
||||
isLoggedIn,
|
||||
ensureLoggedIn,
|
||||
}
|
||||
|
||||
export const USERBOX = {
|
||||
getProfile: (): Promise<{ user: UserBox, items: UserItem[] }> =>
|
||||
get('/api/v2/game/chu3/user-box', {}),
|
||||
setUserBox: (d: { field: string, value: number | string }) =>
|
||||
post(`/api/v2/game/chu3/user-detail-set`, d),
|
||||
}
|
||||
|
||||
export const CARD = {
|
||||
summary: (cardId: string): Promise<{card: Card, summary: CardSummary}> =>
|
||||
post('/api/v2/card/summary', { cardId }),
|
||||
link: (props: { cardId: string, migrate: string }) =>
|
||||
post('/api/v2/card/link', props),
|
||||
unlink: (cardId: string) =>
|
||||
post('/api/v2/card/unlink', { cardId }),
|
||||
userGames: (username: string): Promise<CardSummary> =>
|
||||
post('/api/v2/card/user-games', { username }),
|
||||
}
|
||||
|
||||
export const GAME = {
|
||||
trend: (username: string, game: GameName): Promise<TrendEntry[]> =>
|
||||
post(`/api/v2/game/${game}/trend`, { username }),
|
||||
userSummary: (username: string, game: GameName): Promise<GenericGameSummary> =>
|
||||
post(`/api/v2/game/${game}/user-summary`, { username }),
|
||||
ranking: (game: GameName): Promise<GenericRanking[]> =>
|
||||
post(`/api/v2/game/${game}/ranking`, { }),
|
||||
changeName: (game: GameName, newName: string): Promise<{ newName: string }> =>
|
||||
post(`/api/v2/game/${game}/change-name`, { newName }),
|
||||
export: (game: GameName): Promise<Record<string, any>> =>
|
||||
post(`/api/v2/game/${game}/export`),
|
||||
import: (game: GameName, data: any): Promise<Record<string, any>> =>
|
||||
post(`/api/v2/game/${game}/import`, {}, { body: JSON.stringify(data) }),
|
||||
importMusicDetail: (game: GameName, data: any): Promise<Record<string, any>> =>
|
||||
post(`/api/v2/game/${game}/import-music-detail`, {}, {body: JSON.stringify(data), headers: {'Content-Type': 'application/json'}}),
|
||||
setRival: (game: GameName, rivalUserName: string, isAdd: boolean) =>
|
||||
post(`/api/v2/game/${game}/set-rival`, { rivalUserName, isAdd }),
|
||||
}
|
||||
|
||||
export const DATA = {
|
||||
allMusic: (game: GameName): Promise<AllMusic> =>
|
||||
fetch(`${DATA_HOST}/d/${game}/00/all-music.json`).then(it => it.json()),
|
||||
allItems: (game: GameName): Promise<Record<string, Record<string, any>>> =>
|
||||
fetch(`${DATA_HOST}/d/${game}/00/all-items.json`).then(it => it.json()),
|
||||
}
|
||||
|
||||
export const SETTING = {
|
||||
get: (): Promise<GameOption[]> =>
|
||||
post('/api/v2/settings/get', {}),
|
||||
set: (key: string, value: any) =>
|
||||
post('/api/v2/settings/set', { key, value: `${value}` }),
|
||||
}
|
||||
213
AquaNet/src/libs/ui.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import {
|
||||
CategoryScale,
|
||||
Chart as ChartJS,
|
||||
type ChartOptions,
|
||||
Legend,
|
||||
LinearScale,
|
||||
LineElement,
|
||||
PointElement,
|
||||
TimeScale,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from 'chart.js'
|
||||
import moment from 'moment/moment'
|
||||
// @ts-expect-error Cal-heatmap does not have proper types
|
||||
import CalHeatmap from 'cal-heatmap'
|
||||
// @ts-expect-error Cal-heatmap does not have proper types
|
||||
import CalTooltip from 'cal-heatmap/plugins/Tooltip'
|
||||
import { AQUA_HOST, DEFAULT_PFP } from "./config"
|
||||
import type { AquaNetUser } from "./generalTypes"
|
||||
|
||||
export function title(t: string) {
|
||||
document.title = `AquaNet - ${t}`
|
||||
}
|
||||
|
||||
export function registerChart() {
|
||||
ChartJS.register(
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
LineElement,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
CategoryScale,
|
||||
TimeScale
|
||||
)
|
||||
}
|
||||
|
||||
const dayTemplate = (DateHelper) => {
|
||||
const ROWS_COUNT = 7
|
||||
const ALLOWED_DOMAIN_TYPE = ['month']
|
||||
|
||||
return {
|
||||
name: 'ghDayFix',
|
||||
allowedDomainType: ALLOWED_DOMAIN_TYPE,
|
||||
rowsCount: () => ROWS_COUNT,
|
||||
columnsCount: (ts) => {
|
||||
let count = DateHelper.getWeeksCountInMonth(ts)
|
||||
const endOfMonth = moment().endOf('month').toDate()
|
||||
const clampEnd = DateHelper.getFirstWeekOfMonth(endOfMonth).toDate()
|
||||
|
||||
if(moment(ts).isSame(new Date(), 'month') && endOfMonth > clampEnd) {
|
||||
count++
|
||||
}
|
||||
return count
|
||||
},
|
||||
mapping: (startTimestamp, endTimestamp) => {
|
||||
const clampStart = DateHelper.getFirstWeekOfMonth(startTimestamp)
|
||||
let clampEnd = DateHelper.getFirstWeekOfMonth(endTimestamp)
|
||||
|
||||
if(moment(startTimestamp).isSame(new Date(), 'month')){
|
||||
clampEnd = DateHelper.date().add(1, 'day')
|
||||
}
|
||||
|
||||
let x = -1
|
||||
const pivotDay = clampStart.weekday()
|
||||
|
||||
return DateHelper.intervals('day', clampStart, clampEnd).map((ts) => {
|
||||
const weekday = DateHelper.date(ts).weekday()
|
||||
if (weekday === pivotDay) {
|
||||
x += 1
|
||||
}
|
||||
|
||||
return {
|
||||
t: ts,
|
||||
x,
|
||||
y: weekday,
|
||||
}
|
||||
})
|
||||
},
|
||||
extractUnit: (ts) => DateHelper.date(ts).startOf('day').valueOf(),
|
||||
}
|
||||
}
|
||||
|
||||
export function renderCal(el: HTMLElement, d: { date: any, value: any }[]): Promise<any> {
|
||||
const cal = new CalHeatmap()
|
||||
cal.addTemplates(dayTemplate)
|
||||
return cal.paint({
|
||||
itemSelector: el,
|
||||
domain: {
|
||||
type: 'month',
|
||||
label: { text: 'MMM', textAlign: 'start', position: 'top' },
|
||||
},
|
||||
subDomain: {
|
||||
type: 'ghDayFix',
|
||||
radius: 2, width: 11, height: 11, gutter: 4
|
||||
},
|
||||
range: 12,
|
||||
data: { source: d.filter(x => x.value > 0), x: 'date', y: 'value' },
|
||||
scale: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
range: [ '#14432a', '#4dd05a' ],
|
||||
domain: [ 0, d.reduce((a, b) => Math.max(a, b.value), 0) ]
|
||||
},
|
||||
},
|
||||
date: { start: moment().subtract(1, 'year').add(1, 'month').toDate() },
|
||||
theme: 'dark',
|
||||
}, [
|
||||
[ CalTooltip, {
|
||||
text: (_: Date, v: number, d: any) =>
|
||||
`${v ?? 'No'} songs played on ${d.format('MMMM D, YYYY')}`
|
||||
} ]
|
||||
])
|
||||
}
|
||||
|
||||
|
||||
const now = moment()
|
||||
export const CHARTJS_OPT: ChartOptions<'line'> = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
// TODO: Show point on hover
|
||||
elements: {
|
||||
point: {
|
||||
radius: 0
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
xAxis: {
|
||||
type: 'time',
|
||||
display: false
|
||||
},
|
||||
y: {
|
||||
display: false,
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
tooltip: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
callbacks: {
|
||||
title: (tooltipItems) => {
|
||||
const date = tooltipItems[0].parsed.x
|
||||
const diff = now.diff(date, 'days')
|
||||
return diff ? `${diff} days ago` : 'Today'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export const pfpNotFound = (e: Event) => (e.target as HTMLImageElement).src = DEFAULT_PFP
|
||||
export const coverNotFound = (e: Event) => (e.target as HTMLImageElement).src = "/assets/imgs/no_cover.jpg"
|
||||
|
||||
|
||||
/**
|
||||
* use:tooltip
|
||||
*/
|
||||
export function tooltip(element: HTMLElement, params: { text: string, dom: HTMLElement } | string | HTMLElement) {
|
||||
// Create div if not exists
|
||||
if (!document.querySelector('.aqua-tooltip')) {
|
||||
const div = document.createElement('div')
|
||||
div.classList.add('aqua-tooltip')
|
||||
|
||||
// Initially hidden
|
||||
div.style.display = 'none'
|
||||
|
||||
document.body.appendChild(div)
|
||||
}
|
||||
|
||||
let isFocus = false
|
||||
let div: HTMLDivElement = document.querySelector('.aqua-tooltip')!
|
||||
const p: string = typeof params === 'string' ? params
|
||||
: 'dom' in params ? params.dom.outerHTML
|
||||
: params.outerHTML
|
||||
|
||||
function updatePosition(event: MouseEvent) {
|
||||
div.style.top = `${event.pageY + 10}px`
|
||||
div.style.left = `${event.pageX - div.clientWidth / 2 + 5}px`
|
||||
}
|
||||
|
||||
function mouseOver(event: MouseEvent) {
|
||||
if (isFocus) return
|
||||
div.innerHTML = p
|
||||
div.style.display = ''
|
||||
updatePosition(event)
|
||||
isFocus = true
|
||||
}
|
||||
|
||||
function mouseLeave() {
|
||||
isFocus = false
|
||||
div.style.display = 'none'
|
||||
}
|
||||
|
||||
element.addEventListener('mouseover', mouseOver)
|
||||
element.addEventListener('mouseleave', mouseLeave)
|
||||
element.addEventListener('mousemove', updatePosition)
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
element.removeEventListener('mouseover', mouseOver)
|
||||
element.removeEventListener('mouseleave', mouseLeave)
|
||||
element.removeEventListener('mousemove', updatePosition)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function pfp(node: HTMLImageElement, me?: AquaNetUser) {
|
||||
node.src = me?.profilePicture ? `${AQUA_HOST}/uploads/net/portrait/${me.profilePicture}` : DEFAULT_PFP
|
||||
node.onerror = e => pfpNotFound(e as Event)
|
||||
}
|
||||
336
AquaNet/src/libs/userbox/dds.ts
Normal file
@@ -0,0 +1,336 @@
|
||||
/*
|
||||
|
||||
A simplified DDS parser with Chusan userbox in mind.
|
||||
There are some issues on Safari. I don't really care, to be honest.
|
||||
Authored by Raymond and May.
|
||||
|
||||
DDS header parsing based off of https://gist.github.com/brett19/13c83c2e5e38933757c2
|
||||
|
||||
*/
|
||||
|
||||
import DDSCache from "./ddsCache";
|
||||
|
||||
function makeFourCC(string: string) {
|
||||
return string.charCodeAt(0) +
|
||||
(string.charCodeAt(1) << 8) +
|
||||
(string.charCodeAt(2) << 16) +
|
||||
(string.charCodeAt(3) << 24);
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Magic bytes for the DDS file format (see https://en.wikipedia.org/wiki/Magic_number_(programming))
|
||||
*/
|
||||
const DDS_MAGIC_BYTES = 0x20534444;
|
||||
|
||||
/*
|
||||
to get around the fact that TS's builtin Object.fromEntries() typing
|
||||
doesn't persist strict types and instead only uses broad types
|
||||
without creating a new function to get around it...
|
||||
sorry, this is a really ugly solution, but it's not my problem
|
||||
*/
|
||||
|
||||
/**
|
||||
* @description List of compression type markers used in DDS
|
||||
*/
|
||||
const DDS_COMPRESSION_TYPE_MARKERS = ["DXT1", "DXT3", "DXT5"] as const;
|
||||
|
||||
/**
|
||||
* @description Object mapping string versions of DDS compression type markers to their value in uint32s
|
||||
*/
|
||||
const DDS_COMPRESSION_TYPE_MARKERS_MAP = Object.fromEntries(
|
||||
DDS_COMPRESSION_TYPE_MARKERS
|
||||
.map(e => [e, makeFourCC(e)] as [typeof e, number])
|
||||
) as Record<typeof DDS_COMPRESSION_TYPE_MARKERS[number], number>
|
||||
|
||||
const DDS_DECOMPRESS_VERTEX_SHADER = `
|
||||
attribute vec2 aPosition;
|
||||
varying highp vec2 vTextureCoord;
|
||||
void main() {
|
||||
gl_Position = vec4(aPosition, 0.0, 1.0);
|
||||
vTextureCoord = ((aPosition * vec2(1.0, -1.0)) / 2.0 + 0.5);
|
||||
}`;
|
||||
const DDS_DECOMPRESS_FRAGMENT_SHADER = `
|
||||
varying highp vec2 vTextureCoord;
|
||||
uniform sampler2D uTexture;
|
||||
void main() {
|
||||
gl_FragColor = texture2D(uTexture, vTextureCoord);
|
||||
}`
|
||||
|
||||
export class DDS {
|
||||
constructor(db: IDBDatabase | undefined) {
|
||||
this.cache = new DDSCache(db);
|
||||
|
||||
let gl = this.canvasGL.getContext("webgl");
|
||||
if (!gl) throw new Error("Failed to get WebGL rendering context") // TODO: make it switch to Classic userbox
|
||||
this.gl = gl;
|
||||
|
||||
let ctx = this.canvas2D.getContext("2d");
|
||||
if (!ctx) throw new Error("Failed to reach minimum system requirements") // TODO: make it switch to Classic userbox
|
||||
this.ctx = ctx;
|
||||
|
||||
let ext =
|
||||
gl.getExtension("WEBGL_compressed_texture_s3tc") ||
|
||||
gl.getExtension("MOZ_WEBGL_compressed_texture_s3tc") ||
|
||||
gl.getExtension("WEBKIT_WEBGL_compressed_texture_s3tc");
|
||||
if (!ext) throw new Error("Browser is not supported."); // TODO: make it switch to Classic userbox
|
||||
this.ext = ext;
|
||||
|
||||
/* Initialize shaders */
|
||||
this.compileShaders();
|
||||
this.gl.useProgram(this.shader);
|
||||
|
||||
/* Setup position buffer */
|
||||
let attributeLocation = this.gl.getAttribLocation(this.shader ?? 0, "aPosition");
|
||||
let positionBuffer = this.gl.createBuffer();
|
||||
this.gl.bindBuffer(this.gl.ARRAY_BUFFER, positionBuffer);
|
||||
this.gl.bufferData(this.gl.ARRAY_BUFFER, new Float32Array([1.0, 1.0, -1.0, 1.0, 1.0, -1.0, -1.0, -1.0]), this.gl.STATIC_DRAW);
|
||||
|
||||
this.gl.vertexAttribPointer(
|
||||
attributeLocation,
|
||||
2, this.gl.FLOAT,
|
||||
false, 0, 0
|
||||
);
|
||||
this.gl.enableVertexAttribArray(attributeLocation)
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Loads a DDS file into the internal canvas object.
|
||||
* @param buffer Uint8Array to load DDS from.
|
||||
* @returns String if failed to load, void if success
|
||||
*/
|
||||
load(buffer: Uint8Array) {
|
||||
let header = this.loadHeader(buffer);
|
||||
if (!header) return;
|
||||
|
||||
let compressionMode: GLenum = this.ext.COMPRESSED_RGBA_S3TC_DXT1_EXT;
|
||||
|
||||
if (header.pixelFormat.flags & 0x4) {
|
||||
switch (header.pixelFormat.type) {
|
||||
case DDS_COMPRESSION_TYPE_MARKERS_MAP.DXT1:
|
||||
compressionMode = this.ext.COMPRESSED_RGBA_S3TC_DXT1_EXT;
|
||||
break;
|
||||
case DDS_COMPRESSION_TYPE_MARKERS_MAP.DXT3:
|
||||
compressionMode = this.ext.COMPRESSED_RGBA_S3TC_DXT3_EXT;
|
||||
break;
|
||||
case DDS_COMPRESSION_TYPE_MARKERS_MAP.DXT5:
|
||||
compressionMode = this.ext.COMPRESSED_RGBA_S3TC_DXT5_EXT;
|
||||
break;
|
||||
};
|
||||
} else return;
|
||||
|
||||
/* Initialize and configure the texture */
|
||||
let texture = this.gl.createTexture();
|
||||
this.gl.activeTexture(this.gl.TEXTURE0);
|
||||
this.gl.bindTexture(this.gl.TEXTURE_2D, texture);
|
||||
|
||||
this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MIN_FILTER, this.gl.LINEAR);
|
||||
this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MAG_FILTER, this.gl.LINEAR);
|
||||
this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_S, this.gl.CLAMP_TO_EDGE);
|
||||
this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_T, this.gl.CLAMP_TO_EDGE);
|
||||
|
||||
this.gl.compressedTexImage2D(
|
||||
this.gl.TEXTURE_2D,
|
||||
0,
|
||||
compressionMode,
|
||||
header.width,
|
||||
header.height,
|
||||
0,
|
||||
buffer.slice(128)
|
||||
);
|
||||
|
||||
this.gl.uniform1i(this.gl.getUniformLocation(this.shader || 0, "uTexture"), 0);
|
||||
|
||||
/* Prepare the canvas for drawing */
|
||||
this.canvasGL.width = header.width;
|
||||
this.canvasGL.height = header.height
|
||||
this.gl.viewport(0, 0, this.canvasGL.width, this.canvasGL.height);
|
||||
|
||||
this.gl.clearColor(0.0, 0.0, 0.0, 0.0);
|
||||
this.gl.clear(this.gl.COLOR_BUFFER_BIT);
|
||||
|
||||
this.gl.drawArrays(this.gl.TRIANGLE_STRIP, 0, 4);
|
||||
this.gl.deleteTexture(texture);
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Export a Blob from the parsed DDS texture
|
||||
* @returns DDS texture in specified format
|
||||
* @param inFormat Mime type to export in
|
||||
*/
|
||||
getBlob(inFormat?: string): Promise<Blob | null> {
|
||||
return new Promise(res => this.canvasGL.toBlob(res, inFormat))
|
||||
}
|
||||
get2DBlob(inFormat?: string): Promise<Blob | null> {
|
||||
return new Promise(res => this.canvas2D.toBlob(res, inFormat))
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Helper function to load in a Blob
|
||||
* @input Blob to use
|
||||
*/
|
||||
async fromBlob(input: Blob) {
|
||||
this.load(new Uint8Array(await input.arrayBuffer()));
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Read a DDS file header
|
||||
* @param buffer Uint8Array of the DDS file's contents
|
||||
*/
|
||||
loadHeader(buffer: Uint8Array) {
|
||||
if (this.getUint32(buffer, 0) !== DDS_MAGIC_BYTES) return;
|
||||
|
||||
return {
|
||||
size: this.getUint32(buffer, 4),
|
||||
flags: this.getUint32(buffer, 8),
|
||||
height: this.getUint32(buffer, 12),
|
||||
width: this.getUint32(buffer, 16),
|
||||
mipmaps: this.getUint32(buffer, 24),
|
||||
|
||||
/* TODO: figure out if we can cut any of this out (we totally can btw) */
|
||||
pixelFormat: {
|
||||
size: this.getUint32(buffer, 76),
|
||||
flags: this.getUint32(buffer, 80),
|
||||
type: this.getUint32(buffer, 84),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Retrieve a file from the IndexedDB database and load it into the DDS loader
|
||||
* @param path File path
|
||||
* @returns Whether or not the attempt to retrieve the file was successful
|
||||
*/
|
||||
loadFile(path: string) : Promise<boolean> {
|
||||
return new Promise(async r => {
|
||||
let file = await this.cache?.getFromDatabase(path)
|
||||
if (file != null)
|
||||
await this.fromBlob(file)
|
||||
r(file != null)
|
||||
})
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Retrieve a file from a path
|
||||
* @param path File path
|
||||
* @param fallback Path to a file to fallback to if loading this file fails
|
||||
* @returns An object URL which correlates to a Blob
|
||||
*/
|
||||
async getFile(path: string, fallback?: string) : Promise<string> {
|
||||
if (this.cache?.cached(path))
|
||||
return this.cache.find(path) ?? ""
|
||||
if (!await this.loadFile(path))
|
||||
if (fallback) {
|
||||
if (!await this.loadFile(fallback))
|
||||
return "";
|
||||
} else
|
||||
return ""
|
||||
let blob = await this.getBlob("image/png");
|
||||
if (!blob) return ""
|
||||
return this.cache?.save(
|
||||
path, URL.createObjectURL(blob)
|
||||
) ?? "";
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Transform a spritesheet located at a path to match the dimensions specified in the parameters
|
||||
* @param path Spritesheet path
|
||||
* @param x Crop: X
|
||||
* @param y Crop: Y
|
||||
* @param w Crop: Width
|
||||
* @param h Crop: Height
|
||||
* @param s Scale factor
|
||||
* @returns An object URL which correlates to a Blob
|
||||
*/
|
||||
async getFileFromSheet(path: string, x: number, y: number, w: number, h: number, s?: number): Promise<string> {
|
||||
if (!await this.loadFile(path))
|
||||
return "";
|
||||
this.canvas2D.width = w * (s ?? 1);
|
||||
this.canvas2D.height = h * (s ?? 1);
|
||||
this.ctx.drawImage(this.canvasGL, x, y, w, h, 0, 0, w * (s ?? 1), h * (s ?? 1));
|
||||
|
||||
/* We don't want to cache this, it's a spritesheet piece. */
|
||||
return URL.createObjectURL(await this.get2DBlob("image/png") ?? new Blob([]));
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Retrieve a file and scale it by a specified scale factor
|
||||
* @param path File path
|
||||
* @param s Scale factor
|
||||
* @param fallback Path to a file to fallback to if loading this file fails
|
||||
* @returns An object URL which correlates to a Blob
|
||||
*/
|
||||
async getFileScaled(path: string, s: number, fallback?: string): Promise<string> {
|
||||
if (this.cache?.cached(path, s))
|
||||
return this.cache.find(path, s) ?? ""
|
||||
if (!await this.loadFile(path))
|
||||
if (fallback) {
|
||||
if (!await this.loadFile(fallback))
|
||||
return "";
|
||||
} else
|
||||
return "";
|
||||
this.canvas2D.width = this.canvasGL.width * (s ?? 1);
|
||||
this.canvas2D.height = this.canvasGL.height * (s ?? 1);
|
||||
this.ctx.drawImage(this.canvasGL, 0, 0, this.canvasGL.width, this.canvasGL.height, 0, 0, this.canvasGL.width * (s ?? 1), this.canvasGL.height * (s ?? 1));
|
||||
|
||||
return this.cache?.save(path, URL.createObjectURL(await this.get2DBlob("image/png") ?? new Blob([])), s) ?? "";
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Retrieve a Uint32 from a Uint8Array at the specified offset
|
||||
* @param buffer Uint8Array to retrieve the Uint32 from
|
||||
* @param offset Offset at which to retrieve bytes
|
||||
*/
|
||||
getUint32(buffer: Uint8Array, offset: number) {
|
||||
return (buffer[offset + 0] << 0) +
|
||||
(buffer[offset + 1] << 8) +
|
||||
(buffer[offset + 2] << 16) +
|
||||
(buffer[offset + 3] << 24);
|
||||
};
|
||||
|
||||
private compileShaders() {
|
||||
let vertexShader = this.gl.createShader(this.gl.VERTEX_SHADER);
|
||||
let fragmentShader = this.gl.createShader(this.gl.FRAGMENT_SHADER);
|
||||
|
||||
if (!vertexShader || !fragmentShader) return;
|
||||
|
||||
this.gl.shaderSource(vertexShader, DDS_DECOMPRESS_VERTEX_SHADER);
|
||||
this.gl.compileShader(vertexShader);
|
||||
|
||||
if (!this.gl.getShaderParameter(vertexShader, this.gl.COMPILE_STATUS))
|
||||
throw new Error(
|
||||
`An error occurred compiling vertex shader: ${this.gl.getShaderInfoLog(vertexShader)}`,
|
||||
);
|
||||
|
||||
this.gl.shaderSource(fragmentShader, DDS_DECOMPRESS_FRAGMENT_SHADER);
|
||||
this.gl.compileShader(fragmentShader);
|
||||
|
||||
if (!this.gl.getShaderParameter(fragmentShader, this.gl.COMPILE_STATUS))
|
||||
throw new Error(
|
||||
`An error occurred compiling fragment shader: ${this.gl.getShaderInfoLog(fragmentShader)}`,
|
||||
);
|
||||
|
||||
let program = this.gl.createProgram();
|
||||
|
||||
if (!program) return;
|
||||
this.shader = program;
|
||||
|
||||
this.gl.attachShader(program, vertexShader);
|
||||
this.gl.attachShader(program, fragmentShader);
|
||||
this.gl.linkProgram(program);
|
||||
|
||||
if (!this.gl.getProgramParameter(program, this.gl.LINK_STATUS))
|
||||
throw new Error(
|
||||
`An error occurred linking the program: ${this.gl.getProgramInfoLog(program)}`,
|
||||
);
|
||||
};
|
||||
|
||||
canvas2D: HTMLCanvasElement = document.createElement("canvas");
|
||||
canvasGL: HTMLCanvasElement = document.createElement("canvas");
|
||||
|
||||
cache: DDSCache | null;
|
||||
ctx: CanvasRenderingContext2D;
|
||||
|
||||
gl: WebGLRenderingContext;
|
||||
ext: ReturnType<typeof this.gl.getExtension>;
|
||||
shader: WebGLShader | null = null;
|
||||
};
|
||||
64
AquaNet/src/libs/userbox/ddsCache.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
export default class DDSCache {
|
||||
constructor(db: IDBDatabase | undefined) {
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Finds an object URL for the image with the specified path and scale
|
||||
* @param path Image path
|
||||
* @param scale Scale factor
|
||||
*/
|
||||
find(path: string, scale: number = 1): string | undefined {
|
||||
return (this.urlCache.find(
|
||||
p => p.path == path && p.scale == scale)?.url)
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Checks whether an object URL is cached for the image with the specified path and scale
|
||||
* @param path Image path
|
||||
* @param scale Scale factor
|
||||
*/
|
||||
cached(path: string, scale: number = 1): boolean {
|
||||
return this.urlCache.some(
|
||||
p => p.path == path && p.scale == scale)
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Save an object URL for the specified path and scale to the cache
|
||||
* @param path Image path
|
||||
* @param url Object URL
|
||||
* @param scale Scale factor
|
||||
*/
|
||||
save(path: string, url: string, scale: number = 1) {
|
||||
if (this.cached(path, scale)) {
|
||||
URL.revokeObjectURL(url);
|
||||
return this.find(path, scale)
|
||||
}
|
||||
this.urlCache.push({path, url, scale})
|
||||
return url
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Retrieve a Blob from a database based on the specified path
|
||||
* @param path Image path
|
||||
*/
|
||||
getFromDatabase(path: string): Promise<Blob | null> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.db)
|
||||
return resolve(null);
|
||||
let transaction = this.db.transaction(["dds"], "readonly");
|
||||
let objectStore = transaction.objectStore("dds");
|
||||
let request = objectStore.get(path);
|
||||
request.onsuccess = async (e) => {
|
||||
if (request.result)
|
||||
if (request.result.blob)
|
||||
return resolve(request.result.blob);
|
||||
return resolve(null);
|
||||
}
|
||||
request.onerror = () => resolve(null);
|
||||
})
|
||||
};
|
||||
|
||||
private urlCache: {scale: number, path: string, url: string}[] = [];
|
||||
private db: IDBDatabase | undefined;
|
||||
}
|
||||
180
AquaNet/src/libs/userbox/userbox.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { t, ts } from "../../libs/i18n";
|
||||
import useLocalStorage from "../../libs/hooks/useLocalStorage.svelte";
|
||||
|
||||
const isDirectory = (e: FileSystemEntry): e is FileSystemDirectoryEntry => e.isDirectory
|
||||
const isFile = (e: FileSystemEntry): e is FileSystemFileEntry => e.isFile
|
||||
|
||||
const getDirectory = (directory: FileSystemDirectoryEntry, path: string): Promise<FileSystemEntry> => new Promise((res, rej) => directory.getDirectory(path, {}, d => res(d), e => rej()));
|
||||
const getFile = (directory: FileSystemDirectoryEntry, path: string): Promise<FileSystemEntry> => new Promise((res, rej) => directory.getFile(path, {}, d => res(d), e => rej()));
|
||||
const getFiles = async (directory: FileSystemDirectoryEntry): Promise<Array<FileSystemEntry>> => {
|
||||
let reader = directory.createReader();
|
||||
let files: Array<FileSystemEntry> = [];
|
||||
let currentFiles: number = 1e9;
|
||||
while (currentFiles != 0) {
|
||||
let entries = await new Promise<Array<FileSystemEntry>>(r => reader.readEntries(r));
|
||||
files = files.concat(entries);
|
||||
currentFiles = entries.length;
|
||||
}
|
||||
return files;
|
||||
};
|
||||
|
||||
const validateDirectories = async (base: FileSystemDirectoryEntry, path: string): Promise<boolean> => {
|
||||
const pathTrail = path.split("/");
|
||||
let directory: FileSystemDirectoryEntry = base;
|
||||
for (let part of pathTrail) {
|
||||
let newDirectory = await getDirectory(directory, part).catch(_ => null);
|
||||
if (newDirectory && isDirectory(newDirectory)) {
|
||||
directory = newDirectory;
|
||||
} else
|
||||
return false;
|
||||
};
|
||||
return true
|
||||
}
|
||||
|
||||
const getDirectoryFromPath = async (base: FileSystemDirectoryEntry, path: string): Promise<FileSystemDirectoryEntry | null> => {
|
||||
const pathTrail = path.split("/");
|
||||
let directory: FileSystemDirectoryEntry = base;
|
||||
for (let part of pathTrail) {
|
||||
let newDirectory = await getDirectory(directory, part).catch(_ => null);
|
||||
if (newDirectory && isDirectory(newDirectory)) {
|
||||
directory = newDirectory;
|
||||
} else
|
||||
return null;
|
||||
};
|
||||
return directory;
|
||||
}
|
||||
|
||||
export let ddsDB: IDBDatabase | undefined ;
|
||||
|
||||
/* Technically, processName should be in the translation file but I figured it was such a small thing that it didn't REALLY matter... */
|
||||
const DIRECTORY_PATHS = ([
|
||||
{
|
||||
folder: "ddsImage",
|
||||
processName: "Characters",
|
||||
path: "characterThumbnail",
|
||||
filter: (name: string) => name.substring(name.length - 6, name.length) == "02.dds",
|
||||
id: (name: string) => `0${name.substring(17, 21)}${name.substring(23, 24)}`
|
||||
},
|
||||
{
|
||||
folder: "namePlate",
|
||||
processName: "Nameplates",
|
||||
path: "nameplate",
|
||||
filter: () => true,
|
||||
id: (name: string) => name.substring(17, 25)
|
||||
},
|
||||
{
|
||||
folder: "avatarAccessory",
|
||||
processName: "Avatar Accessory Thumbnails",
|
||||
path: "avatarAccessoryThumbnail",
|
||||
filter: (name: string) => name.substring(14, 18) == "Icon",
|
||||
id: (name: string) => name.substring(19, 27)
|
||||
},
|
||||
{
|
||||
folder: "avatarAccessory",
|
||||
processName: "Avatar Accessories",
|
||||
path: "avatarAccessory",
|
||||
filter: (name: string) => name.substring(14, 17) == "Tex",
|
||||
id: (name: string) => name.substring(18, 26)
|
||||
},
|
||||
{
|
||||
folder: "texture",
|
||||
processName: "Surfboard Textures",
|
||||
useFileName: true,
|
||||
path: "surfboard",
|
||||
filter: (name: string) =>
|
||||
([
|
||||
"CHU_UI_Common_Avatar_body_00.dds",
|
||||
"CHU_UI_Common_Avatar_face_00.dds",
|
||||
"CHU_UI_title_rank_00_v10.dds"
|
||||
]).includes(name),
|
||||
id: (name: string) => name
|
||||
}
|
||||
] satisfies {folder: string, processName: string, path: string, useFileName?: boolean, filter: (name: string) => boolean, id: (name: string) => string}[] )
|
||||
|
||||
export const scanOptionFolder = async (optionFolder: FileSystemDirectoryEntry, progressUpdate: (progress: number, text: string) => void) => {
|
||||
let filesToProcess: Record<string, FileSystemFileEntry[]> = {};
|
||||
let directories = (await getFiles(optionFolder))
|
||||
.filter(directory => isDirectory(directory) && ((directory.name.substring(0, 1) == "A" && directory.name.length == 4) || directory.name == "surfboard"))
|
||||
|
||||
for (let directory of directories)
|
||||
if (isDirectory(directory)) {
|
||||
for (const directoryData of DIRECTORY_PATHS) {
|
||||
let folder = await getDirectoryFromPath(directory, directoryData.folder).catch(_ => null) ?? [];
|
||||
if (folder) {
|
||||
if (!filesToProcess[directoryData.path])
|
||||
filesToProcess[directoryData.path] = [];
|
||||
for (let dataFolderEntry of await getFiles(folder as FileSystemDirectoryEntry).catch(_ => null) ?? [])
|
||||
if (isDirectory(dataFolderEntry)) {
|
||||
for (let dataEntry of await getFiles(dataFolderEntry as FileSystemDirectoryEntry).catch(_ => null) ?? [])
|
||||
if (isFile(dataEntry) && directoryData.filter(dataEntry.name))
|
||||
filesToProcess[directoryData.path].push(dataEntry);
|
||||
} else if (isFile(dataFolderEntry) && directoryData.filter(dataFolderEntry.name))
|
||||
filesToProcess[directoryData.path].push(dataFolderEntry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let data = [];
|
||||
|
||||
for (const [folder, files] of Object.entries(filesToProcess)) {
|
||||
let reference = DIRECTORY_PATHS.find(r => r.path == folder);
|
||||
for (const [idx, file] of files.entries()) {
|
||||
progressUpdate((idx / files.length) * 100, `${t("userbox.new.setup.processing_file")} ${reference?.processName ?? "?"}...`)
|
||||
data.push({
|
||||
path: `${folder}:${reference?.id(file.name)}`, name: file.name, blob: await new Promise<File>(res => file.file(res))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
progressUpdate(100, `${t("userbox.new.setup.finalizing")}...`)
|
||||
|
||||
let transaction = ddsDB?.transaction(['dds'], 'readwrite', { durability: "strict" })
|
||||
if (!transaction) return; // TODO: bubble error up to user
|
||||
transaction.onerror = e => e.preventDefault()
|
||||
let objectStore = transaction.objectStore('dds');
|
||||
for (let object of data)
|
||||
objectStore.put(object)
|
||||
|
||||
// await transaction completion
|
||||
await new Promise(r => transaction.addEventListener("complete", r, {once: true}))
|
||||
};
|
||||
|
||||
export function initializeDb() : Promise<void> {
|
||||
return new Promise(r => {
|
||||
const dbRequest = indexedDB.open("userboxChusanDDS", 1)
|
||||
dbRequest.addEventListener("upgradeneeded", (event) => {
|
||||
if (!(event.target instanceof IDBOpenDBRequest)) return
|
||||
ddsDB = event.target.result;
|
||||
if (!ddsDB) return;
|
||||
|
||||
const store = ddsDB.createObjectStore('dds', { keyPath: 'path' });
|
||||
store.createIndex('path', 'path', { unique: true })
|
||||
store.createIndex('name', 'name', { unique: false })
|
||||
store.createIndex('blob', 'blob', { unique: false })
|
||||
r();
|
||||
});
|
||||
dbRequest.addEventListener("success", () => {
|
||||
ddsDB = dbRequest.result;
|
||||
r();
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export async function userboxFileProcess(folder: FileSystemEntry, progressUpdate: (progress: number, progressString: string) => void): Promise<string | null> {
|
||||
if (!isDirectory(folder))
|
||||
return t("userbox.new.error.invalidFolder")
|
||||
if (!(await validateDirectories(folder, "bin/option")) && !(await validateDirectories(folder, "data/A000")))
|
||||
return t("userbox.new.error.invalidFolder");
|
||||
|
||||
initializeDb();
|
||||
const optionFolder = await getDirectoryFromPath(folder, "bin/option");
|
||||
if (optionFolder)
|
||||
await scanOptionFolder(optionFolder, progressUpdate);
|
||||
const dataFolder = await getDirectoryFromPath(folder, "data");
|
||||
if (dataFolder)
|
||||
await scanOptionFolder(dataFolder, progressUpdate);
|
||||
useLocalStorage("userboxNew", false).value = true;
|
||||
location.reload();
|
||||
|
||||
return null
|
||||
}
|
||||
7
AquaNet/src/main.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { mount } from 'svelte';
|
||||
import './app.sass'
|
||||
import App from './App.svelte'
|
||||
|
||||
const app = mount(App, { target: document.getElementById("app")! });
|
||||
|
||||
export default app
|
||||
98
AquaNet/src/pages/Home.svelte
Normal file
@@ -0,0 +1,98 @@
|
||||
<script lang="ts">
|
||||
import { fade } from "svelte/transition";
|
||||
import LinkCard from "./Home/LinkCard.svelte";
|
||||
import SetupInstructions from "./Home/SetupInstructions.svelte";
|
||||
import { DISCORD_INVITE, FADE_IN, FADE_OUT } from "../libs/config";
|
||||
import { USER } from "../libs/sdk.js";
|
||||
import type { AquaNetUser } from "../libs/generalTypes";
|
||||
import StatusOverlays from "../components/StatusOverlays.svelte";
|
||||
import ActionCard from "../components/ActionCard.svelte";
|
||||
import { t } from "../libs/i18n";
|
||||
import ImportDataAction from "./Home/ImportDataAction.svelte";
|
||||
import Communities from "./Home/Communities.svelte";
|
||||
|
||||
USER.ensureLoggedIn();
|
||||
|
||||
let me: AquaNetUser
|
||||
let error = ""
|
||||
|
||||
let tab = 0;
|
||||
let tabs = [t('home.nav.portal'), t('home.nav.link-card'), t('home.nav.game-setup')]
|
||||
|
||||
USER.me().then((m) => me = m).catch(e => error = e.message)
|
||||
</script>
|
||||
|
||||
<main class="content">
|
||||
<!-- <h2 class="outer-title"> </h2>-->
|
||||
<nav class="tabs">
|
||||
{#each tabs as t, i}
|
||||
<div class="clickable"
|
||||
class:active={tab === i}
|
||||
on:click={() => tab = i}
|
||||
on:keydown={(e) => e.key === "Enter" && (tab = i)}
|
||||
role="button" tabindex={i}>{t}
|
||||
</div>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
{#if tab === 0}
|
||||
<div out:fade={FADE_OUT} in:fade={FADE_IN} class="action-cards">
|
||||
<ActionCard color="255, 192, 203" icon="solar:card-bold-duotone" on:click={() => tab = 1}>
|
||||
{#if me && me.cards.length > 1}
|
||||
<h3>{t('home.manage-cards')}</h3>
|
||||
<span>{t('home.manage-cards-description')}</span>
|
||||
{:else if me}
|
||||
<h3>{t('home.link-card')}</h3>
|
||||
<span>{t('home.link-cards-description')}</span>
|
||||
{/if}
|
||||
</ActionCard>
|
||||
|
||||
<ActionCard color="82, 93, 233" icon="fluent:chat-12-filled" on:click={() => tab = 3}>
|
||||
<h3>{t('home.join-community')}</h3>
|
||||
<span>{t('home.join-community-description')}</span>
|
||||
</ActionCard>
|
||||
|
||||
<ActionCard on:click={() => tab = 2} icon="uil:link-alt">
|
||||
<h3>{t('home.setup')}</h3>
|
||||
<span>{t('home.setup-description')}</span>
|
||||
</ActionCard>
|
||||
|
||||
<ImportDataAction/>
|
||||
</div>
|
||||
{:else if tab === 1}
|
||||
<div out:fade={FADE_OUT} in:fade={FADE_IN}>
|
||||
<LinkCard/>
|
||||
</div>
|
||||
{:else if tab === 2}
|
||||
<div out:fade={FADE_OUT} in:fade={FADE_IN}>
|
||||
<SetupInstructions/>
|
||||
</div>
|
||||
{:else if tab === 3}
|
||||
<div out:fade={FADE_OUT} in:fade={FADE_IN}>
|
||||
<Communities/>
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<StatusOverlays {error} loading={!me}/>
|
||||
|
||||
<style lang="sass">
|
||||
@use "../vars"
|
||||
|
||||
.tabs
|
||||
display: flex
|
||||
gap: 1rem
|
||||
|
||||
div
|
||||
&.active
|
||||
color: vars.$c-main
|
||||
|
||||
h3
|
||||
font-size: 1.3rem
|
||||
margin: 0
|
||||
|
||||
.action-cards
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: 1rem
|
||||
</style>
|
||||
26
AquaNet/src/pages/Home/Communities.svelte
Normal file
@@ -0,0 +1,26 @@
|
||||
<!-- Svelte 4.2.11 -->
|
||||
|
||||
<script lang="ts">
|
||||
import { t } from "../../libs/i18n";
|
||||
import CommunityCard from "../../components/CommunityCard.svelte";
|
||||
import { DISCORD_INVITE, QQ_INVITE, TELEGRAM_INVITE } from "../../libs/config";
|
||||
</script>
|
||||
|
||||
<div class="setup-instructions">
|
||||
<h2>{t('home.join-community')}</h2>
|
||||
<div class="grid cols-3 gap-4">
|
||||
<CommunityCard color="82, 93, 233" icon="ic:baseline-discord" on:click={() => window.location.href = DISCORD_INVITE}>
|
||||
<h3>Discord</h3>
|
||||
</CommunityCard>
|
||||
<CommunityCard color="46, 163, 224" icon="mingcute:telegram-fill" on:click={() => window.location.href = TELEGRAM_INVITE}>
|
||||
<h3>Telegram</h3>
|
||||
</CommunityCard>
|
||||
<CommunityCard color="226, 60, 68" icon="ri:qq-fill" on:click={() => window.location.href = QQ_INVITE}>
|
||||
<h3>QQ</h3>
|
||||
</CommunityCard>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="sass">
|
||||
|
||||
</style>
|
||||
162
AquaNet/src/pages/Home/ImportDataAction.svelte
Normal file
@@ -0,0 +1,162 @@
|
||||
<script lang="ts">
|
||||
import { fade } from "svelte/transition"
|
||||
import { t } from "../../libs/i18n";
|
||||
import ActionCard from "../../components/ActionCard.svelte";
|
||||
import StatusOverlays from "../../components/StatusOverlays.svelte";
|
||||
import { CARD, GAME, USER } from "../../libs/sdk";
|
||||
import Icon from "@iconify/svelte";
|
||||
|
||||
let load = false;
|
||||
let error = "";
|
||||
let conflict: {
|
||||
oldName: string,
|
||||
oldRating: number,
|
||||
newName: string,
|
||||
newRating: number
|
||||
} | null;
|
||||
let confirmAction: (override: boolean) => void;
|
||||
|
||||
let fileInput: HTMLInputElement;
|
||||
const startImport = async (e: Event & { currentTarget: EventTarget & HTMLInputElement; }) => {
|
||||
const file = e.currentTarget.files?.[0]
|
||||
|
||||
if (!file) return;
|
||||
load = true;
|
||||
|
||||
try {
|
||||
const data = JSON.parse(await file.text()) as any;
|
||||
const me = await USER.me();
|
||||
|
||||
const maybeUserMusicList = data?.userMusicList || data;
|
||||
if (Array.isArray(maybeUserMusicList) && maybeUserMusicList.every(it => Array.isArray(it?.userMusicDetailList))) {
|
||||
// Is music list array
|
||||
await GAME.importMusicDetail("mai2", maybeUserMusicList.flatMap(it => it.userMusicDetailList));
|
||||
location.href = `/u/${me.username}/mai2`;
|
||||
return;
|
||||
}
|
||||
|
||||
const game = getGameByCode(data.gameId);
|
||||
const userGames = await CARD.userGames(me.username);
|
||||
|
||||
const existed = userGames[game];
|
||||
if (existed) {
|
||||
conflict = {
|
||||
oldName: existed.name,
|
||||
oldRating: existed.rating,
|
||||
newName: data.userData.userName,
|
||||
newRating: data.userData.playerRating
|
||||
};
|
||||
if (!await new Promise(resolve => confirmAction = resolve)) {
|
||||
return;
|
||||
}
|
||||
conflict = null;
|
||||
}
|
||||
|
||||
await GAME.import(game, data);
|
||||
location.href = `/u/${me.username}/${game}`;
|
||||
} catch (e: any) {
|
||||
error = e.message;
|
||||
console.error(e);
|
||||
} finally {
|
||||
conflict = null;
|
||||
load = false;
|
||||
}
|
||||
}
|
||||
|
||||
const getGameByCode = (code: string) => {
|
||||
switch (code?.toUpperCase()) {
|
||||
case 'SDEZ':
|
||||
return 'mai2';
|
||||
case 'SDHD':
|
||||
return 'chu3';
|
||||
default:
|
||||
throw new Error(t('home.import.unknown-game'));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<ActionCard color="209, 124, 102" icon="bxs:file-import" on:click={() => fileInput.click()}>
|
||||
<h3>{t('home.import')}</h3>
|
||||
<span>{t('home.import-description')}</span>
|
||||
<input type="file" accept=".json" bind:this={fileInput} style="display: none"
|
||||
on:change={startImport}/>
|
||||
</ActionCard>
|
||||
|
||||
<StatusOverlays {error} loading={load}/>
|
||||
|
||||
{#if conflict}
|
||||
<div class="overlay" transition:fade>
|
||||
<div>
|
||||
<h2>{t('home.import.data-conflict')}</h2>
|
||||
<p></p>
|
||||
<div class="conflict-cards">
|
||||
<div class="old card">
|
||||
<span class="type">{t('home.linkcard.account-card')}</span>
|
||||
<span>{t('home.linkcard.name')}: {conflict.oldName}</span>
|
||||
<span>{t('home.linkcard.rating')}: {conflict.oldRating}</span>
|
||||
<div class="trash">
|
||||
<Icon icon="ph:trash-duotone"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="icon">
|
||||
<Icon icon="icon-park-outline:down"/>
|
||||
</div>
|
||||
<div class="new card">
|
||||
<span class="type">{t('home.import.new-data')}</span>
|
||||
<span>{t('home.linkcard.name')}: {conflict.newName}</span>
|
||||
<span>{t('home.linkcard.rating')}: {conflict.newRating}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p></p>
|
||||
<div class="buttons">
|
||||
<button on:click={() => confirmAction(false)}>{t('action.cancel')}</button>
|
||||
<button class="error" on:click={() => confirmAction(true)}>{t('action.confirm')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style lang="sass">
|
||||
@use "../../vars"
|
||||
h3
|
||||
font-size: 1.3rem
|
||||
margin: 0
|
||||
|
||||
.conflict-cards
|
||||
display: grid
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr))
|
||||
gap: 0.5rem
|
||||
align-items: center
|
||||
|
||||
span:not(.type)
|
||||
font-size: 0.8rem
|
||||
|
||||
.old
|
||||
background: #ff6b6b20
|
||||
border: 1px solid vars.$c-error
|
||||
color: #ffffff99
|
||||
position: relative
|
||||
|
||||
.trash
|
||||
display: flex
|
||||
position: absolute
|
||||
bottom: 0.5rem
|
||||
right: 0.5rem
|
||||
color: vars.$c-error
|
||||
opacity: 0.6
|
||||
font-size: 2rem
|
||||
|
||||
.new
|
||||
background: #646cff20
|
||||
border: 1px solid vars.$c-darker
|
||||
|
||||
.buttons
|
||||
display: grid
|
||||
grid-template-columns: 1fr 1fr
|
||||
gap: 1rem
|
||||
|
||||
.icon
|
||||
display: flex
|
||||
justify-content: center
|
||||
font-size: 2rem
|
||||
</style>
|
||||
375
AquaNet/src/pages/Home/LinkCard.svelte
Normal file
@@ -0,0 +1,375 @@
|
||||
<!-- Svelte 4.2.11 -->
|
||||
|
||||
<script lang="ts">
|
||||
import { fade, slide } from "svelte/transition"
|
||||
import type { Card, CardSummary, CardSummaryGame, ConfirmProps, AquaNetUser } from "../../libs/generalTypes";
|
||||
import { CARD, USER } from "../../libs/sdk";
|
||||
import moment from "moment"
|
||||
import Icon from "@iconify/svelte";
|
||||
import StatusOverlays from "../../components/StatusOverlays.svelte";
|
||||
import { t } from "../../libs/i18n";
|
||||
|
||||
// State
|
||||
let state: 'ready' | 'linking-AC' | 'linking-SN' | 'loading' = "loading"
|
||||
let showConfirm: ConfirmProps | null = null
|
||||
|
||||
let error: string = ""
|
||||
let me: AquaNetUser | null = null
|
||||
let accountCardSummary: CardSummary | null = null
|
||||
|
||||
// Fetch data for current user
|
||||
const updateMe = () => USER.me().then(m => {
|
||||
me = m
|
||||
m.cards.sort((a, b) => a.registerTime < b.registerTime ? 1 : -1)
|
||||
CARD.summary(m.ghostCard.luid).then(s => accountCardSummary = s.summary)
|
||||
|
||||
// Always put the ghost card at the top
|
||||
m.cards.sort((a, b) => a.isGhost ? -1 : 1)
|
||||
state = "ready"
|
||||
}).catch(e => error = e.message)
|
||||
updateMe()
|
||||
|
||||
// Data conflict overlay
|
||||
let conflictCardID: string = ""
|
||||
let conflictSummary: CardSummary | null = null
|
||||
let conflictGame: string = ""
|
||||
let conflictNew: CardSummaryGame | null = null
|
||||
let conflictOld: CardSummaryGame | null = null
|
||||
let conflictToMigrate: string[] = []
|
||||
|
||||
function setError(msg: string, type: 'AC' | 'SN') {
|
||||
type === 'AC' ? errorAC = msg : errorSN = msg
|
||||
}
|
||||
|
||||
async function doLink(id: string, migrate: string) {
|
||||
await CARD.link({cardId: id, migrate})
|
||||
await updateMe()
|
||||
state = "ready"
|
||||
}
|
||||
|
||||
async function link(type: 'AC' | 'SN') {
|
||||
if (state !== 'ready' || accountCardSummary === null) return
|
||||
state = "linking-" + type
|
||||
const id = type === 'AC' ? inputAC : inputSN
|
||||
|
||||
console.log("linking card", id)
|
||||
|
||||
// Check if this card is already linked in the account
|
||||
if (me?.cards?.some(c => formatLUID(c.luid, c.isGhost).toLowerCase() === id.toLowerCase())) {
|
||||
setError(t('home.linkcard.linked-own'), type)
|
||||
state = "ready"
|
||||
return
|
||||
}
|
||||
|
||||
// First, lookup the card summary
|
||||
const card = (await CARD.summary(id).catch(e => {
|
||||
// If card is not found, create a card and link it
|
||||
if (e.message === t('home.linkcard.notfound')) {
|
||||
doLink(id, "")
|
||||
return
|
||||
}
|
||||
|
||||
setError(e.message, type)
|
||||
state = "ready"
|
||||
return
|
||||
}))!
|
||||
const summary = card.summary
|
||||
|
||||
// Check if it's already linked
|
||||
if (card.card.linked) {
|
||||
setError(t('home.linkcard.linked-another'), type)
|
||||
state = "ready"
|
||||
return
|
||||
}
|
||||
|
||||
// If all games in summary are null or doesn't conflict with the ghost card,
|
||||
// we can link the card directly
|
||||
if (Object.keys(summary).every(k => summary[k as keyof CardSummary] === null
|
||||
|| accountCardSummary!![k as keyof CardSummary] === null)) {
|
||||
console.log("linking card directly")
|
||||
await doLink(id, Object.keys(summary).filter(k => summary[k as keyof CardSummary] !== null).join(","))
|
||||
}
|
||||
|
||||
// For each conflicting game, ask the user if they want to migrate the data
|
||||
else {
|
||||
conflictSummary = summary
|
||||
conflictCardID = id
|
||||
await linkConflictContinue(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function linkConflictContinue(choose: "old" | "new" | null) {
|
||||
if (accountCardSummary === null || conflictSummary === null) return
|
||||
console.log("linking card with migration")
|
||||
|
||||
if (choose) {
|
||||
// If old is chosen, nothing needs to be migrated
|
||||
// If new is chosen, we need to migrate the data
|
||||
if (choose === "new") {
|
||||
conflictToMigrate.push(conflictGame)
|
||||
}
|
||||
// Continue to the next card
|
||||
conflictSummary[conflictGame as keyof CardSummary] = null
|
||||
}
|
||||
|
||||
let isConflict = false
|
||||
for (const k in conflictSummary) {
|
||||
conflictNew = conflictSummary[k as keyof CardSummary]
|
||||
conflictOld = accountCardSummary[k as keyof CardSummary]
|
||||
conflictGame = k
|
||||
if (!conflictNew || !conflictOld) continue
|
||||
|
||||
isConflict = true
|
||||
break
|
||||
}
|
||||
|
||||
// If there are no longer conflicts, we can link the card
|
||||
if (!isConflict) {
|
||||
await doLink(conflictCardID, conflictToMigrate.join(","))
|
||||
|
||||
// Reset the conflict state
|
||||
linkConflictCancel()
|
||||
}
|
||||
}
|
||||
|
||||
function linkConflictCancel() {
|
||||
state = "ready"
|
||||
conflictSummary = null
|
||||
conflictCardID = ""
|
||||
conflictGame = ""
|
||||
conflictNew = null
|
||||
conflictOld = null
|
||||
conflictToMigrate = []
|
||||
}
|
||||
|
||||
async function unlink(card: Card) {
|
||||
showConfirm = {
|
||||
title: t('home.linkcard.unlink'),
|
||||
message: t('home.linkcard.unlink-notice'),
|
||||
confirm: async () => {
|
||||
await CARD.unlink(card.luid)
|
||||
await updateMe()
|
||||
showConfirm = null
|
||||
},
|
||||
cancel: () => showConfirm = null,
|
||||
dangerous: true
|
||||
}
|
||||
}
|
||||
|
||||
// Access code input
|
||||
const inputACRegex = /^(\d{4} ){0,4}\d{0,4}$/
|
||||
let inputAC = ""
|
||||
let errorAC = ""
|
||||
|
||||
function inputACChange(e: any) {
|
||||
e = e as InputEvent
|
||||
// Add spaces to the input
|
||||
const old = inputAC
|
||||
if (e.inputType === "insertText" && inputAC.length % 5 === 4 && inputAC.length < 24)
|
||||
inputAC += " "
|
||||
inputAC = inputAC.slice(0, 24)
|
||||
if (inputAC !== old) errorAC = ""
|
||||
}
|
||||
|
||||
// Serial number input
|
||||
const inputSNRegex = /^([0-9A-Fa-f]{0,2}:){0,7}[0-9A-Fa-f]{0,2}$/
|
||||
let inputSN = ""
|
||||
let errorSN = ""
|
||||
|
||||
function inputSNChange(e: any) {
|
||||
e = e as InputEvent
|
||||
// Add colons to the input
|
||||
const old = inputSN
|
||||
if (e.inputType === "insertText" && inputSN.length % 3 === 2 && inputSN.length < 23)
|
||||
inputSN += ":"
|
||||
inputSN = inputSN.toUpperCase().slice(0, 23)
|
||||
if (inputSN !== old) errorSN = ""
|
||||
}
|
||||
|
||||
function formatLUID(luid: string, ghost: boolean = false) {
|
||||
if (ghost) return luid.slice(0, 6) + " " + (luid.slice(6).match(/.{4}/g)?.join(" ") ?? "")
|
||||
switch (cardType(luid)) {
|
||||
case "Felica SN":
|
||||
return BigInt(luid).toString(16).toUpperCase().padStart(16, "0").match(/.{1,2}/g)!.join(":")
|
||||
case "Access Code":
|
||||
return luid.match(/.{4}/g)!.join(" ")
|
||||
default:
|
||||
return luid
|
||||
}
|
||||
}
|
||||
|
||||
function cardType(luid: string) {
|
||||
if (luid.startsWith("00")) return "Felica SN"
|
||||
if (luid.length === 20) return "Access Code"
|
||||
if (luid.includes(":")) return "Felica SN"
|
||||
if (luid.includes(" ")) return "Access Code"
|
||||
return "Unknown"
|
||||
}
|
||||
|
||||
function isInput(e: KeyboardEvent) {
|
||||
return e.key.length === 1 && !e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="link-card">
|
||||
<h2>{t('home.linkcard.cards')}</h2>
|
||||
<p>{t('home.linkcard.description')}:</p>
|
||||
|
||||
{#if me}
|
||||
<div class="existing-cards" transition:slide>
|
||||
{#each me.cards as card (card.luid)}
|
||||
<div class:ghost={card.isGhost} class='existing card' transition:fade|global>
|
||||
<span class="type">{card.isGhost ? t('home.linkcard.account-card') : cardType(card.luid)}</span>
|
||||
<span class="register">{t('home.linkcard.registered')}: {moment(card.registerTime).format("YYYY MMM DD")}</span>
|
||||
<span class="last">{t('home.linkcard.lastused')}: {moment(card.accessTime).format("YYYY MMM DD")}</span>
|
||||
<div></div>
|
||||
<span class="id">{formatLUID(card.luid, card.isGhost)}</span>
|
||||
{#if !card.isGhost}
|
||||
<button class="icon error" on:click={() => unlink(card)}><Icon icon="tabler:trash-x-filled"/></button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<h2>{t('home.link-card')}</h2>
|
||||
<p>{t('home.linkcard.enter-info')}:</p>
|
||||
{#if !inputSN}
|
||||
<div out:slide={{ duration: 250 }}>
|
||||
<p>{t('home.linkcard.access-code')}</p>
|
||||
<label>
|
||||
<!-- DO NOT change the order of bind:value and on:input. Their order determines the order of reactivity -->
|
||||
<input placeholder="e.g. 5200 1234 5678 9012 3456"
|
||||
on:keydown={(e) => {
|
||||
e.key === "Enter" && link('AC')
|
||||
// Ensure key is numeric
|
||||
if (isInput(e) && !/[\d ]/.test(e.key)) e.preventDefault()
|
||||
}}
|
||||
bind:value={inputAC}
|
||||
on:input={inputACChange}
|
||||
class:error={inputAC && (!inputACRegex.test(inputAC) || errorAC)}>
|
||||
{#if inputAC.length > 0}
|
||||
<button transition:slide={{axis: 'x'}} on:click={() => {link('AC');inputAC=''}}>{t('home.linkcard.link')}</button>
|
||||
{/if}
|
||||
</label>
|
||||
{#if errorAC}
|
||||
<p class="error" transition:slide>{errorAC}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !inputAC}
|
||||
<div out:slide={{ duration: 250 }}>
|
||||
<p>{t('home.linkcard.enter-sn1')}
|
||||
(<a href="https://play.google.com/store/apps/details?id=com.wakdev.wdnfc">Android</a> /
|
||||
<a href="https://apps.apple.com/us/app/nfc-tools/id1252962749">Apple</a>)
|
||||
{t('home.linkcard.enter-sn2')}
|
||||
</p>
|
||||
<label>
|
||||
<input placeholder="e.g. 01:2E:1A:2B:3C:4D:5E:6F"
|
||||
on:keydown={(e) => {
|
||||
e.key === "Enter" && link('SN')
|
||||
// Ensure key is hex or colon
|
||||
if (isInput(e) && !/[0-9A-Fa-f:]/.test(e.key)) e.preventDefault()
|
||||
}}
|
||||
bind:value={inputSN}
|
||||
on:input={inputSNChange}
|
||||
class:error={inputSN && (!inputSNRegex.test(inputSN) || errorSN)}>
|
||||
{#if inputSN.length > 0}
|
||||
<button transition:slide={{axis: 'x'}} on:click={() => {link('SN'); inputSN = ''}}>{t('home.linkcard.link')}</button>
|
||||
{/if}
|
||||
</label>
|
||||
{#if errorSN}
|
||||
<p class="error" transition:slide>{errorSN}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if conflictOld && conflictNew && me}
|
||||
<div class="overlay" transition:fade>
|
||||
<div>
|
||||
<h2>{t('home.linkcard.data-conflict')}</h2>
|
||||
<p></p>
|
||||
<div class="conflict-cards">
|
||||
<div class="old card clickable" on:click={() => linkConflictContinue('old')}
|
||||
role="button" tabindex="0" on:keydown={e => e.key === "Enter" && linkConflictContinue('old')}>
|
||||
<span class="type">{t('home.linkcard.account-card')}</span>
|
||||
<span>{t('home.linkcard.name')}: {conflictOld.name}</span>
|
||||
<span>{t('home.linkcard.rating')}: {conflictOld.rating}</span>
|
||||
<span>{t('home.linkcard.last-login')}: {moment(conflictOld.lastLogin).format("YYYY MMM DD")}</span>
|
||||
<span class="id">{formatLUID(me.ghostCard.luid, true)}</span>
|
||||
</div>
|
||||
<div class="new card clickable" on:click={() => linkConflictContinue('new')}
|
||||
role="button" tabindex="0" on:keydown={e => e.key === "Enter" && linkConflictContinue('new')}>
|
||||
<span class="type">{cardType(conflictCardID)}</span>
|
||||
<span>{t('home.linkcard.name')}: {conflictNew.name}</span>
|
||||
<span>{t('home.linkcard.rating')}: {conflictNew.rating}</span>
|
||||
<span>{t('home.linkcard.last-login')}: {moment(conflictNew.lastLogin).format("YYYY MMM DD")}</span>
|
||||
<span class="id">{conflictCardID}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="error" on:click={linkConflictCancel}>{t('action.cancel')}</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<StatusOverlays bind:confirm={showConfirm} bind:error={error} loading={!me} />
|
||||
</div>
|
||||
|
||||
<style lang="sass">
|
||||
@use "../../vars"
|
||||
|
||||
.link-card
|
||||
input
|
||||
width: 100%
|
||||
|
||||
label
|
||||
display: flex
|
||||
|
||||
button
|
||||
margin-left: 1rem
|
||||
|
||||
.existing-cards, .conflict-cards
|
||||
display: grid
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr))
|
||||
gap: 1rem
|
||||
|
||||
.existing-cards .existing.card
|
||||
min-height: 90px
|
||||
position: relative
|
||||
overflow: hidden
|
||||
|
||||
*
|
||||
white-space: nowrap
|
||||
|
||||
&.ghost
|
||||
background: rgba(vars.$c-darker, 0.8)
|
||||
|
||||
.register, .last
|
||||
opacity: 0.7
|
||||
|
||||
span:not(.type)
|
||||
font-size: 0.8rem
|
||||
|
||||
> div
|
||||
flex: 1
|
||||
|
||||
button
|
||||
position: absolute
|
||||
right: 10px
|
||||
bottom: 10px
|
||||
|
||||
.conflict-cards
|
||||
.card
|
||||
transition: vars.$transition
|
||||
|
||||
.card:hover
|
||||
background: vars.$c-darker
|
||||
|
||||
span:not(.type)
|
||||
font-size: 0.8rem
|
||||
|
||||
.id
|
||||
opacity: 0.7
|
||||
|
||||
</style>
|
||||
103
AquaNet/src/pages/Home/SetupInstructions.svelte
Normal file
@@ -0,0 +1,103 @@
|
||||
<!-- Svelte 4.2.11 -->
|
||||
|
||||
<script lang="ts">
|
||||
import { fade, slide } from "svelte/transition";
|
||||
import { USER } from "../../libs/sdk";
|
||||
import type { AquaNetUser } from "../../libs/generalTypes";
|
||||
import { codeToHtml } from 'shiki'
|
||||
import { AQUA_CONNECTION, DISCORD_INVITE, FADE_IN, FADE_OUT } from "../../libs/config";
|
||||
import { t } from "../../libs/i18n";
|
||||
|
||||
let user: AquaNetUser
|
||||
let keychip: string;
|
||||
let keychipCode: string;
|
||||
let getStartedRequesting = false;
|
||||
|
||||
USER.me().then((u) => {
|
||||
user = u;
|
||||
});
|
||||
|
||||
function getStarted() {
|
||||
if (getStartedRequesting) return;
|
||||
|
||||
getStartedRequesting = true;
|
||||
USER.keychip().then(k => {
|
||||
getStartedRequesting = false;
|
||||
keychip = k;
|
||||
codeToHtml(`
|
||||
[dns]
|
||||
default=${AQUA_CONNECTION}
|
||||
|
||||
[keychip]
|
||||
enable=1
|
||||
; ${t('home.setup.keychip-tips')}
|
||||
id=${keychip.slice(0, 4)}-${keychip.slice(4)}1337`.trim(), {
|
||||
lang: 'ini',
|
||||
theme: 'rose-pine',
|
||||
}).then((html) => {
|
||||
keychipCode = html;
|
||||
});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="setup-instructions">
|
||||
<h2>{t('home.setup')}</h2>
|
||||
<p>
|
||||
{t('home.setup.welcome')}
|
||||
</p>
|
||||
<blockquote>
|
||||
{t('home.setup.blockquote')}
|
||||
</blockquote>
|
||||
|
||||
{#if user}
|
||||
<div transition:slide>
|
||||
{#if !keychip && !keychipCode}
|
||||
<div class="no-margin" out:fade={FADE_OUT}>
|
||||
<button class="emp" on:click={getStarted}>{t('home.setup.get')}</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="no-margin" in:fade={FADE_IN}>
|
||||
<p>
|
||||
{t('home.setup.edit')}:
|
||||
</p>
|
||||
|
||||
<div class="code">
|
||||
{@html keychipCode}
|
||||
</div>
|
||||
|
||||
<p>
|
||||
{t('home.setup.test')}
|
||||
</p>
|
||||
<p>
|
||||
{t('home.setup.ask')} <a href={DISCORD_INVITE}>Discord</a> {t('home.setup.support')}.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<p>Loading...</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="sass">
|
||||
.code
|
||||
overflow-x: auto
|
||||
|
||||
:global(pre.shiki)
|
||||
background-color: transparent !important
|
||||
|
||||
:global(code)
|
||||
counter-reset: step
|
||||
counter-increment: step 0
|
||||
|
||||
:global(code .line::before)
|
||||
content: counter(step)
|
||||
counter-increment: step
|
||||
width: 1rem
|
||||
margin-right: 1.5rem
|
||||
display: inline-block
|
||||
text-align: right
|
||||
color: rgba(115,138,148,.4)
|
||||
|
||||
</style>
|
||||
136
AquaNet/src/pages/Ranking.svelte
Normal file
@@ -0,0 +1,136 @@
|
||||
<script lang="ts">
|
||||
import { title } from "../libs/ui";
|
||||
import { GAME } from "../libs/sdk";
|
||||
import type { GenericRanking } from "../libs/generalTypes";
|
||||
import StatusOverlays from "../components/StatusOverlays.svelte";
|
||||
import type { GameName } from "../libs/scoring";
|
||||
import { GAME_TITLE } from "../libs/i18n";
|
||||
import { t } from "../libs/i18n";
|
||||
import UserCard from "../components/UserCard.svelte";
|
||||
import Tooltip from "../components/Tooltip.svelte";
|
||||
|
||||
export let game: GameName = 'mai2';
|
||||
|
||||
title(`Ranking`);
|
||||
|
||||
let d: { users: GenericRanking[] };
|
||||
let error: string | null;
|
||||
Promise.all([GAME.ranking(game)])
|
||||
.then(([users]) => {
|
||||
console.log(users)
|
||||
d = { users };
|
||||
})
|
||||
.catch((e) => error = e.message);
|
||||
|
||||
let hoveringUser = "";
|
||||
let hoverLoading = false;
|
||||
</script>
|
||||
|
||||
<main class="content leaderboard">
|
||||
<div class="outer-title-options">
|
||||
<h2>{t("Leaderboard.Title")}</h2>
|
||||
<nav>
|
||||
{#each Object.entries(GAME_TITLE) as [k, v]}
|
||||
<a href="/ranking/{k}" class:active={k === game}>{v}</a>
|
||||
{/each}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{#if d}
|
||||
<div class="leaderboard-container">
|
||||
<div class="lb-user" on:mouseenter={() => hoveringUser = d.users[0].username} role="heading" aria-level="2">
|
||||
<span class="rank">{t("Leaderboard.Rank")}</span>
|
||||
<span class="name"></span>
|
||||
<span class="rating">{t("Leaderboard.Rating")}</span>
|
||||
<span class="accuracy">{t("Leaderboard.Accuracy")}</span>
|
||||
<span class="fc">{t("Leaderboard.FC")}</span>
|
||||
<span class="ap">{t("Leaderboard.AP")}</span>
|
||||
</div>
|
||||
{#each d.users as user, i (user.rank)}
|
||||
<div class="lb-user" class:alternate={i % 2 === 1} role="listitem"
|
||||
on:mouseover={() => hoveringUser = user.username} on:focus={() => {}}>
|
||||
|
||||
<span class="rank">#{user.rank}</span>
|
||||
<span class="name">
|
||||
{#if user.username !== ""}
|
||||
<a href="/u/{user.username}/{game}" class:registered={!(/user\d+/.test(user.username))}>{user.name}</a>
|
||||
{:else}
|
||||
<span>{user.name}</span>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="rating">{
|
||||
game === 'chu3' ?
|
||||
(user.rating / 100).toFixed(2) :
|
||||
user.rating.toLocaleString()
|
||||
}</span>
|
||||
<span class="accuracy">{(+user.accuracy).toFixed(2)}%</span>
|
||||
<span class="fc">{user.fullCombo}</span>
|
||||
<span class="ap">{user.allPerfect}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<Tooltip triggeredBy=".name" loading={hoverLoading}>
|
||||
<UserCard username={hoveringUser} {game} setLoading={l => hoverLoading = l} />
|
||||
</Tooltip>
|
||||
{/if}
|
||||
|
||||
<StatusOverlays error={error} loading={!d} />
|
||||
</main>
|
||||
|
||||
<style lang="sass">
|
||||
@use "../vars"
|
||||
|
||||
.leaderboard-container
|
||||
display: flex
|
||||
flex-direction: column
|
||||
|
||||
.lb-user
|
||||
display: flex
|
||||
align-items: center
|
||||
justify-content: space-between
|
||||
width: 100%
|
||||
gap: 12px
|
||||
border-radius: vars.$border-radius
|
||||
padding: 6px 12px
|
||||
box-sizing: border-box
|
||||
|
||||
> *:not(.name)
|
||||
text-align: center
|
||||
|
||||
.name
|
||||
min-width: 100px
|
||||
flex: 1
|
||||
|
||||
> a
|
||||
color: unset
|
||||
|
||||
.registered
|
||||
background: vars.$grad-special
|
||||
color: transparent
|
||||
-webkit-background-clip: text
|
||||
background-clip: text
|
||||
|
||||
.accuracy, .rating
|
||||
width: 15%
|
||||
min-width: 45px
|
||||
|
||||
.rating
|
||||
font-weight: bold
|
||||
color: white
|
||||
|
||||
.fc, .ap
|
||||
width: 5%
|
||||
min-width: 20px
|
||||
|
||||
@media (max-width: vars.$w-mobile)
|
||||
font-size: 0.9rem
|
||||
|
||||
.accuracy
|
||||
display: none
|
||||
|
||||
&.alternate
|
||||
background-color: vars.$ov-light
|
||||
|
||||
|
||||
</style>
|
||||
200
AquaNet/src/pages/User/Settings.svelte
Normal file
@@ -0,0 +1,200 @@
|
||||
<!-- Svelte 4.2.11 -->
|
||||
|
||||
<script lang="ts">
|
||||
import { slide, fade } from "svelte/transition";
|
||||
import type { AquaNetUser } from "../../libs/generalTypes";
|
||||
import { CARD, USER } from "../../libs/sdk";
|
||||
import StatusOverlays from "../../components/StatusOverlays.svelte";
|
||||
import Icon from "@iconify/svelte";
|
||||
import { pfp } from "../../libs/ui";
|
||||
import { t, ts } from "../../libs/i18n";
|
||||
import { FADE_IN, FADE_OUT } from "../../libs/config";
|
||||
import UserBox from "../../components/settings/ChuniSettings.svelte";
|
||||
import Mai2Settings from "../../components/settings/Mai2Settings.svelte";
|
||||
import WaccaSettings from "../../components/settings/WaccaSettings.svelte";
|
||||
import GeneralGameSettings from "../../components/settings/GeneralGameSettings.svelte";
|
||||
|
||||
USER.ensureLoggedIn()
|
||||
|
||||
let me: AquaNetUser;
|
||||
let error: string;
|
||||
let submitting = ""
|
||||
let tab = 0
|
||||
let tabs = [ 'profile', 'game' ]
|
||||
|
||||
const profileFields = [
|
||||
[ 'displayName', t('settings.profile.name') ],
|
||||
[ 'username', t('settings.profile.username') ],
|
||||
[ 'password', t('settings.profile.password') ],
|
||||
[ 'profileLocation', t('settings.profile.location') ],
|
||||
[ 'profileBio', t('settings.profile.bio') ],
|
||||
] as const
|
||||
|
||||
// Fetch user data
|
||||
const getMe = () => USER.me().then((m) => {
|
||||
me = m
|
||||
|
||||
CARD.userGames(m.username).then(games => {
|
||||
if (games.chu3 && !tabs.includes('chu3')) {
|
||||
tabs = [...tabs, 'chu3']
|
||||
}
|
||||
if (games.mai2 && !tabs.includes('mai2')) {
|
||||
tabs = [...tabs, 'mai2']
|
||||
}
|
||||
if (games.wacca && !tabs.includes('wacca')) {
|
||||
tabs = [...tabs, 'wacca']
|
||||
}
|
||||
})
|
||||
}).catch(e => error = e.message)
|
||||
getMe()
|
||||
|
||||
let changed: string[] = []
|
||||
let pfpField: HTMLInputElement
|
||||
|
||||
function submit(field: string, value: string) {
|
||||
if (submitting) return
|
||||
submitting = field
|
||||
|
||||
USER.setting(field, value).then(() => {
|
||||
changed = changed.filter(c => c !== field)
|
||||
}).catch(e => error = e.message).finally(() => submitting = "")
|
||||
}
|
||||
|
||||
function uploadPfp(file: File) {
|
||||
if (submitting) return
|
||||
submitting = 'profilePicture'
|
||||
|
||||
USER.uploadPfp(file).then(() => {
|
||||
me.profilePicture = file.name
|
||||
// reload
|
||||
getMe()
|
||||
}).catch(e => error = e.message).finally(() => submitting = "")
|
||||
}
|
||||
|
||||
const passwordAction = (node: HTMLInputElement, whether: boolean) => {
|
||||
if (whether) node.type = 'password'
|
||||
}
|
||||
</script>
|
||||
|
||||
<main class="content">
|
||||
<div class="outer-title-options">
|
||||
<h2>{t('settings.title')}</h2>
|
||||
<nav>
|
||||
{#each tabs as tabName, i}
|
||||
<div transition:slide={{axis: 'x'}} class:active={tab === i}
|
||||
on:click={() => tab = i} on:keydown={e => e.key === 'Enter' && (tab = i)}
|
||||
role="button" tabindex="0">
|
||||
{ts(`settings.tabs.${tabName}`)}
|
||||
</div>
|
||||
{/each}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{#if tab === 0 && me}
|
||||
<!-- Tab 0: Profile settings -->
|
||||
<div out:fade={FADE_OUT} in:fade={FADE_IN} class="fields">
|
||||
<div class="field">
|
||||
<label for="profile-upload">{t('settings.profile.picture')}</label>
|
||||
<div>
|
||||
{#if me && me.profilePicture}
|
||||
<div on:click={() => pfpField.click()} on:keydown={e => e.key === 'Enter' && pfpField.click()}
|
||||
role="button" tabindex="0" class="clickable">
|
||||
<img use:pfp={me} alt="Profile" />
|
||||
</div>
|
||||
{:else}
|
||||
<button on:click={() => pfpField.click()}>
|
||||
{t('settings.profile.upload-new')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<input id="profile-upload" type="file" accept="image/*" style="display: none" bind:this={pfpField}
|
||||
on:change={() => pfpField.files && uploadPfp(pfpField.files[0])} />
|
||||
</div>
|
||||
|
||||
{#each profileFields as [field, name], i (field)}
|
||||
<div class="field">
|
||||
<label for={field}>{name}</label>
|
||||
<div>
|
||||
<input id={field} type="text" use:passwordAction={field === 'password'}
|
||||
bind:value={me[field]} on:input={() => changed = [...changed, field]}
|
||||
placeholder={field === 'password' ? t('settings.profile.unchanged') : t('settings.profile.unset')}/>
|
||||
{#if changed.includes(field) && me[field]}
|
||||
<button transition:slide={{axis: 'x'}} on:click={() => submit(field, me[field])}>
|
||||
{#if submitting === field}
|
||||
<Icon icon="line-md:loading-twotone-loop" />
|
||||
{:else}
|
||||
{t('settings.profile.save')}
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
<div class="field m-t">
|
||||
<div class="bool">
|
||||
<input id="optOutOfLeaderboard" type="checkbox" bind:checked={me.optOutOfLeaderboard}
|
||||
on:change={() => submit('optOutOfLeaderboard', me.optOutOfLeaderboard.toString())}/>
|
||||
<label for="optOutOfLeaderboard">
|
||||
<span class="name">{ts(`settings.fields.optOutOfLeaderboard.name`)}</span>
|
||||
<span class="desc">{ts(`settings.fields.optOutOfLeaderboard.desc`)}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if tabs[tab] === 'chu3'}
|
||||
<!-- Userbox settings -->
|
||||
<UserBox />
|
||||
{:else if tabs[tab] === 'mai2'}
|
||||
<Mai2Settings username={me.username} />
|
||||
{:else if tabs[tab] === 'wacca'}
|
||||
<WaccaSettings />
|
||||
{:else if tabs[tab] === 'game'}
|
||||
<GeneralGameSettings />
|
||||
{/if}
|
||||
|
||||
<StatusOverlays {error} loading={!me || !!submitting} />
|
||||
</main>
|
||||
|
||||
<style lang="sass">
|
||||
@use "../../vars"
|
||||
|
||||
.fields
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: 12px
|
||||
|
||||
.bool
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: 1rem
|
||||
|
||||
label
|
||||
display: flex
|
||||
flex-direction: column
|
||||
|
||||
.desc
|
||||
opacity: 0.6
|
||||
|
||||
.field
|
||||
display: flex
|
||||
flex-direction: column
|
||||
|
||||
label
|
||||
max-width: max-content
|
||||
|
||||
> div:not(.bool)
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: 1rem
|
||||
margin-top: 0.5rem
|
||||
|
||||
> input
|
||||
flex: 1
|
||||
|
||||
img
|
||||
max-width: 100px
|
||||
max-height: 100px
|
||||
border-radius: vars.$border-radius
|
||||
object-fit: cover
|
||||
|
||||
</style>
|
||||
579
AquaNet/src/pages/UserHome.svelte
Normal file
@@ -0,0 +1,579 @@
|
||||
<script lang="ts">
|
||||
import { CHARTJS_OPT, coverNotFound, pfpNotFound, registerChart, renderCal, title, tooltip, pfp } from "../libs/ui";
|
||||
import type {
|
||||
GenericGamePlaylog,
|
||||
GenericGameSummary,
|
||||
MusicMeta,
|
||||
TrendEntry,
|
||||
AquaNetUser,
|
||||
|
||||
AllMusic
|
||||
|
||||
} from "../libs/generalTypes";
|
||||
import { DATA_HOST } from "../libs/config";
|
||||
import 'cal-heatmap/cal-heatmap.css';
|
||||
import moment from "moment";
|
||||
import 'chartjs-adapter-moment';
|
||||
import { CARD, DATA, GAME, USER } from "../libs/sdk";
|
||||
import { type GameName, getMult, roundFloor } from "../libs/scoring";
|
||||
import StatusOverlays from "../components/StatusOverlays.svelte";
|
||||
import Icon from "@iconify/svelte";
|
||||
import { GAME_TITLE, t } from "../libs/i18n";
|
||||
import RankDetails from "../components/RankDetails.svelte";
|
||||
import RatingComposition from "../components/RatingComposition.svelte";
|
||||
import useLocalStorage from "../libs/hooks/useLocalStorage.svelte";
|
||||
import Line from "../components/chart/Line.svelte";
|
||||
|
||||
const TREND_DAYS = 60
|
||||
|
||||
registerChart()
|
||||
|
||||
export let username: string;
|
||||
export let game: GameName = "mai2"
|
||||
let calElement: HTMLElement
|
||||
let error: string;
|
||||
let me: AquaNetUser
|
||||
title(`User ${username}`)
|
||||
const rounding = useLocalStorage("rounding", true);
|
||||
|
||||
const titleText = GAME_TITLE[game]
|
||||
|
||||
interface MusicAndPlay extends MusicMeta, GenericGamePlaylog {}
|
||||
|
||||
let d: {
|
||||
user: GenericGameSummary,
|
||||
trend: TrendEntry[]
|
||||
recent: MusicAndPlay[],
|
||||
validGames: [ string, string ][],
|
||||
} | null
|
||||
|
||||
let allMusics: AllMusic
|
||||
let showDetailRank = false
|
||||
let isLoading = false
|
||||
|
||||
function init() {
|
||||
USER.isLoggedIn() && USER.me().then(u => me = u)
|
||||
|
||||
CARD.userGames(username).then(games => {
|
||||
if (!games[game]) {
|
||||
// Find a valid game
|
||||
const valid = Object.entries(games).filter(([g, valid]) => valid)
|
||||
if (!valid || !valid[0]) return error = t("UserHome.NoValidGame")
|
||||
window.location.href = `/u/${username}/${valid[0][0]}`
|
||||
}
|
||||
|
||||
Promise.all([
|
||||
GAME.userSummary(username, game),
|
||||
GAME.trend(username, game),
|
||||
DATA.allMusic(game),
|
||||
]).then(([user, trend, music]) => {
|
||||
console.log(user)
|
||||
console.log(trend)
|
||||
console.log(games)
|
||||
|
||||
// If game is wacca, divide all ratings by 10
|
||||
if (game === 'wacca') {
|
||||
user.rating /= 10
|
||||
trend.forEach(it => it.rating /= 10)
|
||||
user.recent.forEach(it => {
|
||||
it.beforeRating /= 10
|
||||
it.afterRating /= 10
|
||||
})
|
||||
}
|
||||
|
||||
const minDate = moment().subtract(TREND_DAYS, 'days').format("YYYY-MM-DD")
|
||||
d = {user,
|
||||
trend: trend.filter(it => it.date >= minDate && it.plays != 0),
|
||||
recent: user.recent.map(it => {return {...music[it.musicId], ...it}}),
|
||||
validGames: Object.entries(GAME_TITLE).filter(g => games[g[0] as GameName])
|
||||
}
|
||||
allMusics = music
|
||||
renderCal(calElement, trend.map(it => {return {date: it.date, value: it.plays}})).then(() => {
|
||||
// Scroll to the rightmost
|
||||
calElement.scrollLeft = calElement.scrollWidth - calElement.clientWidth
|
||||
})
|
||||
}).catch((e) => error = e.message);
|
||||
}).catch((e) => { error = e.message; console.error(e) } );
|
||||
}
|
||||
|
||||
if (Object.keys(GAME_TITLE).includes(game)) init()
|
||||
else error = t("UserHome.InvalidGame", {game})
|
||||
|
||||
const setRival = (isAdd: boolean) => {
|
||||
isLoading = true
|
||||
GAME.setRival(game, username, isAdd).then(() => {
|
||||
d!.user.rival = isAdd
|
||||
}).catch(e => error = e.message).finally(() => isLoading = false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<main id="user-home" class="content">
|
||||
{#if d}
|
||||
<div class="user-pfp">
|
||||
<img use:pfp={d.user.aquaUser} alt="" class="pfp" on:error={pfpNotFound}>
|
||||
<div class="name-box">
|
||||
<h2>{d.user.name}</h2>
|
||||
{#if typeof d.user.rival === 'boolean' && game === 'mai2'}
|
||||
<span class="clickable" on:click={() => setRival(!d?.user.rival)} role="button" tabindex="0"
|
||||
on:keydown={e => e.key === "Enter" && setRival(!d?.user.rival)}>
|
||||
{d.user.rival ? t("UserHome.RemoveRival") : t("UserHome.AddRival")}
|
||||
</span>
|
||||
{/if}
|
||||
{#if me && me.username === username}
|
||||
<a class="setting-icon clickable" use:tooltip={t("UserHome.Settings")} href="/settings">
|
||||
<Icon icon="eos-icons:rotating-gear"/>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
<nav>
|
||||
{#each d.validGames as [g, name]}
|
||||
<a href={`/u/${username}/${g}`} class:active={game === g}>{name}</a>
|
||||
{/each}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2>{titleText} {t('UserHome.Statistics')}</h2>
|
||||
<div class="scoring-info">
|
||||
<div class="chart">
|
||||
<div class="info-top">
|
||||
<div class="rating">
|
||||
<span>{game === 'mai2' ? t("UserHome.DXRating"): t("UserHome.Rating")}</span>
|
||||
<span>{
|
||||
game === 'chu3' ?
|
||||
(d.user.rating / 100).toFixed(2) :
|
||||
d.user.rating.toLocaleString()
|
||||
}</span>
|
||||
</div>
|
||||
|
||||
<div class="rank">
|
||||
<span>{t('UserHome.ServerRank')}</span>
|
||||
<span>#{(d.user.serverRank + 1).toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="trend">
|
||||
<!-- ChartJS cannot be fully responsive unless there is a parent div that's independent from its size and helps it determine its size -->
|
||||
<div class="chartjs-box-reference">
|
||||
{#if d.trend.length <= 1}
|
||||
<div class="no-data">{t("UserHome.NoData", { days: TREND_DAYS })}</div>
|
||||
{:else}
|
||||
<Line data={{
|
||||
datasets: [
|
||||
{
|
||||
label: 'Rating',
|
||||
data: d.trend.map(it => {return {x: Date.parse(it.date), y: it.rating}}),
|
||||
borderColor: '#646cff',
|
||||
tension: 0.1,
|
||||
|
||||
// TODO: Set X axis span to 3 months
|
||||
}
|
||||
]
|
||||
}} options={CHARTJS_OPT} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if Object.entries(d.user.detailedRanks).length > 0}
|
||||
<div class="info-bottom clickable" use:tooltip={t("UserHome.ShowRanksDetails")}
|
||||
on:click={() => showDetailRank = !showDetailRank} role="button" tabindex="0"
|
||||
on:keydown={e => e.key === "Enter" && (showDetailRank = !showDetailRank)}>
|
||||
{#each d.user.ranks as r}
|
||||
<div><span>{r.name}</span><span>{r.count}</span></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="info-bottom">
|
||||
{#each d.user.ranks as r}
|
||||
<div><span>{r.name}</span><span>{r.count}</span></div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="other-info">
|
||||
<div class="accuracy">
|
||||
<span>{t('UserHome.Accuracy')}</span>
|
||||
<span>{(d.user.accuracy).toFixed(2)}%</span>
|
||||
</div>
|
||||
|
||||
<div class="max-combo">
|
||||
<span>{t("UserHome.MaxCombo")}</span>
|
||||
<span>{d.user.maxCombo}</span>
|
||||
</div>
|
||||
|
||||
<div class="full-combo">
|
||||
<span>{t("UserHome.FullCombo")}</span>
|
||||
<span>{d.user.fullCombo}</span>
|
||||
</div>
|
||||
|
||||
<div class="all-perfect">
|
||||
<span>{t("UserHome.AllPerfect")}</span>
|
||||
<span>{d.user.allPerfect}</span>
|
||||
</div>
|
||||
|
||||
<div class="total-dx-score">
|
||||
<span>{game === 'mai2' ? t('UserHome.DXScore') : t("UserHome.Score")}</span>
|
||||
<span>{d.user.totalScore.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showDetailRank}<RankDetails g={d.user}/>{/if}
|
||||
|
||||
<div>
|
||||
<h2>{t('UserHome.PlayActivity')}</h2>
|
||||
<div class="activity-info">
|
||||
<div class="hide-scrollbar" id="cal-heatmap" bind:this={calElement}></div>
|
||||
|
||||
<div class="info-bottom">
|
||||
<div class="plays">
|
||||
<span>{t("UserHome.Plays")}</span>
|
||||
<span>{d.user.plays}</span>
|
||||
</div>
|
||||
|
||||
<div class="time">
|
||||
<span>{t('UserHome.PlayTime')}</span>
|
||||
<span>{(d.user.totalPlayTime / 60).toFixed(1)} hr</span>
|
||||
</div>
|
||||
|
||||
<div class="first-play">
|
||||
<span>{t('UserHome.FirstSeen')}</span>
|
||||
<span>{moment(d.user.joined).format("YYYY-MM-DD")}</span>
|
||||
</div>
|
||||
|
||||
<div class="last-play">
|
||||
<span>{t('UserHome.LastSeen')}</span>
|
||||
<span>{moment(d.user.lastSeen).format("YYYY-MM-DD")}</span>
|
||||
</div>
|
||||
|
||||
<div class="last-version">
|
||||
<span>{t('UserHome.Version')}</span>
|
||||
<span>{d.user.lastVersion}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RatingComposition title="B30" comp={d.user.ratingComposition.best30} {allMusics} {game}/>
|
||||
<RatingComposition title="B35" comp={d.user.ratingComposition.best35} {allMusics} {game}/>
|
||||
<RatingComposition title="B15" comp={d.user.ratingComposition.best15} {allMusics} {game}/>
|
||||
<!-- <RatingComposition title="Hot 10" comp={d.user.ratingComposition.hot10} {allMusics} {game}/> -->
|
||||
<!-- <RatingComposition title="N10" comp={d.user.ratingComposition.next10} {allMusics} {game}/> -->
|
||||
<RatingComposition title="Recent 10" comp={d.user.ratingComposition.recent10} {allMusics} {game} top={10}/>
|
||||
|
||||
<div class="recent">
|
||||
<h2>{t('UserHome.RecentScores')}</h2>
|
||||
<div class="scores">
|
||||
{#each d.recent as r, i}
|
||||
<div class:alt={i % 2 === 0}>
|
||||
<img src={`${DATA_HOST}/d/${game}/music/00${r.musicId.toString().padStart(6, '0').substring(2)}.png`} alt="" on:error={coverNotFound} />
|
||||
<div class="info">
|
||||
<div>{r.name ?? t("UserHome.UnknownSong")}</div>
|
||||
<div>
|
||||
{#if r.isAllPerfect || r.isAllJustice}
|
||||
<img src="/assets/imgs/All Perfect.png" alt="All Perfect" />
|
||||
{:else if r.isFullCombo}
|
||||
<img src="/assets/imgs/Full Combo.png" alt="Full Combo" />
|
||||
{/if}
|
||||
<span class={`lv level-${r.level === 10 ? 5 : r.level}`}>
|
||||
<span>
|
||||
{r.notes?.[r.level === 10 ? 0 : r.level]?.lv?.toFixed(1) ?? r.worldsEndTag ?? '-'}
|
||||
</span>
|
||||
</span>
|
||||
<span class={`rank-${getMult(r.achievement, game)[2].toString()[0]}`}>
|
||||
<span class="rank-text">{("" + getMult(r.achievement, game)[2]).replace("p", "+")}</span>
|
||||
<span class="rank-num" use:tooltip={(r.achievement / 10000).toFixed(4)}>
|
||||
{
|
||||
rounding.value ?
|
||||
roundFloor(r.achievement, game, 1) :
|
||||
(r.achievement / 10000).toFixed(4)
|
||||
}%
|
||||
</span>
|
||||
</span>
|
||||
{#if game === 'mai2' || game === 'wacca'}
|
||||
<span class:increased={r.afterRating - r.beforeRating > 0} class="dx-change">
|
||||
{r.afterRating === r.beforeRating ? '-' : (r.afterRating - r.beforeRating).toFixed(0)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<StatusOverlays {error} loading={!d || isLoading} />
|
||||
</main>
|
||||
|
||||
<style lang="sass">
|
||||
@use "../vars"
|
||||
|
||||
#user-home
|
||||
.user-pfp
|
||||
display: flex
|
||||
align-items: flex-end
|
||||
gap: vars.$gap
|
||||
margin-top: -72px
|
||||
position: relative
|
||||
|
||||
h2
|
||||
font-size: 2rem
|
||||
margin: 0
|
||||
white-space: nowrap
|
||||
|
||||
nav
|
||||
position: absolute
|
||||
display: flex
|
||||
flex-direction: row
|
||||
gap: 10px
|
||||
top: 4px
|
||||
right: 0
|
||||
|
||||
.setting-icon
|
||||
font-size: 1.5rem
|
||||
color: vars.$c-main
|
||||
cursor: pointer
|
||||
display: flex
|
||||
align-items: center
|
||||
|
||||
.name-box
|
||||
flex: 1
|
||||
display: flex
|
||||
align-items: center
|
||||
justify-content: space-between
|
||||
gap: 10px
|
||||
|
||||
.pfp
|
||||
width: 100px
|
||||
height: 100px
|
||||
border-radius: vars.$border-radius
|
||||
object-fit: cover
|
||||
|
||||
@media (max-width: vars.$w-mobile)
|
||||
.user-pfp
|
||||
margin-top: -68px
|
||||
h2
|
||||
font-size: 1.5rem
|
||||
|
||||
.pfp
|
||||
width: 80px
|
||||
height: 80px
|
||||
|
||||
.info-bottom, .info-top, .other-info
|
||||
display: flex
|
||||
gap: vars.$gap
|
||||
|
||||
> div
|
||||
display: flex
|
||||
flex-direction: column
|
||||
|
||||
> span:first-child
|
||||
font-weight: bold
|
||||
font-size: 0.8rem
|
||||
|
||||
// character spacing
|
||||
letter-spacing: 0.1em
|
||||
color: vars.$c-main
|
||||
|
||||
.info-bottom
|
||||
width: max-content
|
||||
|
||||
.info-top > div > span:last-child
|
||||
font-size: 1.5rem
|
||||
|
||||
.info-bottom
|
||||
max-width: 100%
|
||||
flex-wrap: wrap
|
||||
row-gap: 0.5rem
|
||||
|
||||
.scoring-info
|
||||
display: flex
|
||||
gap: vars.$gap
|
||||
max-height: 250px
|
||||
|
||||
.chart
|
||||
flex: 0 1 790px
|
||||
display: flex
|
||||
flex-direction: column
|
||||
|
||||
.other-info
|
||||
flex: 1 0 100px
|
||||
flex-direction: column
|
||||
gap: 0
|
||||
justify-content: space-between
|
||||
|
||||
.trend
|
||||
height: 300px
|
||||
width: 100%
|
||||
max-width: 790px
|
||||
|
||||
position: relative
|
||||
|
||||
> .chartjs-box-reference
|
||||
position: absolute
|
||||
inset: 0
|
||||
display: flex
|
||||
align-items: center
|
||||
justify-content: center
|
||||
|
||||
.no-data
|
||||
opacity: 0.5
|
||||
user-select: none
|
||||
|
||||
@media (max-width: vars.$w-mobile)
|
||||
flex-direction: column
|
||||
max-height: unset
|
||||
|
||||
.chart
|
||||
flex: 0
|
||||
|
||||
.trend
|
||||
max-height: 130px
|
||||
|
||||
.other-info
|
||||
> div
|
||||
flex-direction: row
|
||||
justify-content: space-between
|
||||
|
||||
.info-bottom
|
||||
justify-content: space-between
|
||||
|
||||
.activity-info
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: vars.$gap
|
||||
|
||||
#cal-heatmap
|
||||
overflow-x: auto
|
||||
|
||||
@media (max-width: vars.$w-mobile)
|
||||
#cal-heatmap
|
||||
width: 100%
|
||||
|
||||
.info-bottom
|
||||
flex-direction: column
|
||||
gap: 0
|
||||
width: 100%
|
||||
|
||||
> div
|
||||
flex-direction: row
|
||||
justify-content: space-between
|
||||
|
||||
// Recent Scores section
|
||||
.recent
|
||||
.scores
|
||||
display: flex
|
||||
flex-direction: column
|
||||
flex-wrap: wrap
|
||||
gap: vars.$gap
|
||||
|
||||
> div.alt
|
||||
background-color: rgba(white, 0.03)
|
||||
border-radius: vars.$border-radius
|
||||
|
||||
// Image and song info
|
||||
> div
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: vars.$gap
|
||||
padding-right: 16px
|
||||
max-width: 100%
|
||||
box-sizing: border-box
|
||||
|
||||
img
|
||||
width: 50px
|
||||
height: 50px
|
||||
border-radius: vars.$border-radius
|
||||
object-fit: cover
|
||||
|
||||
// Song info and score
|
||||
> div.info
|
||||
flex: 1
|
||||
display: flex
|
||||
justify-content: space-between
|
||||
align-items: center
|
||||
overflow: hidden
|
||||
|
||||
// Limit song name to one line
|
||||
> div:first-child
|
||||
flex: 1
|
||||
min-width: 0
|
||||
overflow: hidden
|
||||
text-overflow: ellipsis
|
||||
white-space: nowrap
|
||||
|
||||
// Make song score and rank not wrap
|
||||
> div:last-child
|
||||
white-space: nowrap
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: 10px
|
||||
|
||||
img
|
||||
height: 1.5em
|
||||
width: 1.5em
|
||||
|
||||
@media (max-width: vars.$w-mobile)
|
||||
flex-direction: column
|
||||
gap: 0
|
||||
|
||||
.rank-text
|
||||
text-align: left
|
||||
|
||||
// Score and rank should be space-between on mobile
|
||||
> div:last-child
|
||||
display: flex
|
||||
justify-content: space-between
|
||||
gap: 10px
|
||||
|
||||
.lv
|
||||
margin-right: auto
|
||||
|
||||
.rank-S
|
||||
// Gold green gradient on text
|
||||
background: vars.$grad-special
|
||||
-webkit-background-clip: text
|
||||
color: transparent
|
||||
|
||||
.rank-A
|
||||
color: #ff8a8a
|
||||
|
||||
.rank-B
|
||||
color: #6ba6ff
|
||||
|
||||
.lv
|
||||
min-width: 30px
|
||||
text-align: center
|
||||
background: rgba(var(--lv-color), 0.6)
|
||||
padding: 0 6px
|
||||
border-radius: vars.$border-radius
|
||||
|
||||
.lv.level-5 > span
|
||||
color: transparent
|
||||
background: var(--lv-text-clip)
|
||||
background-clip: text
|
||||
-webkit-background-clip: text
|
||||
font-weight: bold
|
||||
font-size: 1em
|
||||
font-family: 'Arial Black', sans-serif
|
||||
|
||||
span
|
||||
display: inline-block
|
||||
text-align: right
|
||||
|
||||
// Vertical table-like alignment
|
||||
span.rank-text
|
||||
min-width: 38px
|
||||
span.rank-num
|
||||
min-width: 60px
|
||||
span.dx-change
|
||||
min-width: 30px
|
||||
|
||||
span.increased
|
||||
&:before
|
||||
content: "+"
|
||||
color: vars.$c-good
|
||||
</style>
|
||||
251
AquaNet/src/pages/Welcome.svelte
Normal file
@@ -0,0 +1,251 @@
|
||||
<script lang="ts">
|
||||
import { Turnstile } from "svelte-turnstile";
|
||||
import { slide } from 'svelte/transition';
|
||||
import { TURNSTILE_SITE_KEY } from "../libs/config";
|
||||
import Icon from "@iconify/svelte";
|
||||
import { USER } from "../libs/sdk";
|
||||
import { t } from "../libs/i18n"
|
||||
|
||||
let params = new URLSearchParams(window.location.search)
|
||||
|
||||
let state = "home"
|
||||
$: isSignup = state === "signup"
|
||||
let submitting = false
|
||||
|
||||
let email = ""
|
||||
let password = ""
|
||||
let username = ""
|
||||
let turnstile = ""
|
||||
let turnstileReset: () => void | undefined;
|
||||
|
||||
let error = ""
|
||||
let verifyMsg = ""
|
||||
|
||||
if (params.get('confirm-email')) {
|
||||
state = 'verify'
|
||||
verifyMsg = t("welcome.verifying")
|
||||
submitting = true
|
||||
|
||||
// Send request to server
|
||||
USER.confirmEmail(params.get('confirm-email')!)
|
||||
.then(() => {
|
||||
verifyMsg = t('welcome.verified')
|
||||
submitting = false
|
||||
|
||||
// Clear the query param
|
||||
window.history.replaceState({}, document.title, window.location.pathname)
|
||||
})
|
||||
.catch(e => verifyMsg = t('welcome.verification-failed', { message: e.message }))
|
||||
}
|
||||
|
||||
async function submit(): Promise<any> {
|
||||
submitting = true
|
||||
|
||||
// Check if username and password are valid
|
||||
if (email === "" || password === "") {
|
||||
error = t("welcome.email-password-missing")
|
||||
return submitting = false
|
||||
}
|
||||
|
||||
if (turnstile === "") {
|
||||
// Sleep for 100ms to allow Turnstile to finish
|
||||
error = t("welcome.waiting-turnstile")
|
||||
return setTimeout(submit, 100)
|
||||
}
|
||||
|
||||
// Signup
|
||||
if (isSignup) {
|
||||
if (username === "") {
|
||||
error = t("welcome.username-missing")
|
||||
return submitting = false
|
||||
}
|
||||
|
||||
// Send request to server
|
||||
await USER.register({ username, email, password, turnstile })
|
||||
.then(() => {
|
||||
// Show verify email message
|
||||
state = 'verify'
|
||||
verifyMsg = t("welcome.verification-sent", { email })
|
||||
})
|
||||
.catch(e => {
|
||||
error = e.message
|
||||
submitting = false
|
||||
turnstileReset()
|
||||
})
|
||||
}
|
||||
else {
|
||||
// Send request to server
|
||||
await USER.login({ email, password, turnstile })
|
||||
.then(() => window.location.href = "/home")
|
||||
.catch(e => {
|
||||
if (e.message === 'Email not verified - STATE_0') {
|
||||
state = 'verify'
|
||||
verifyMsg = t("welcome.verify-state-0")
|
||||
}
|
||||
else if (e.message === 'Email not verified - STATE_1') {
|
||||
state = 'verify'
|
||||
verifyMsg = t("welcome.verify-state-1")
|
||||
}
|
||||
else if (e.message === 'Email not verified - STATE_2') {
|
||||
state = 'verify'
|
||||
verifyMsg = t("welcome.verify-state-2")
|
||||
}
|
||||
else {
|
||||
error = e.message
|
||||
submitting = false
|
||||
turnstileReset()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
submitting = false
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<main id="home" class="no-margin">
|
||||
<div>
|
||||
<h1 id="title">AquaNet</h1>
|
||||
{#if state === "home"}
|
||||
<div class="btn-group" transition:slide>
|
||||
<button on:click={() => state = 'login'}>{t('welcome.btn-login')}</button>
|
||||
<button on:click={() => state = 'signup'}>{t('welcome.btn-signup')}</button>
|
||||
</div>
|
||||
{:else if state === "login" || state === "signup"}
|
||||
<div class="login-form" transition:slide>
|
||||
{#if error}
|
||||
<span class="error">{error}</span>
|
||||
{/if}
|
||||
<div on:click={() => state = 'home'} on:keypress={() => state = 'home'}
|
||||
role="button" tabindex="0" class="clickable">
|
||||
<Icon icon="line-md:chevron-small-left" />
|
||||
<span>{t('back')}</span>
|
||||
</div>
|
||||
{#if isSignup}
|
||||
<input type="text" placeholder={t('username')} bind:value={username}>
|
||||
{/if}
|
||||
<input type="email" placeholder={t('email')} bind:value={email}>
|
||||
<input type="password" placeholder={t('password')} bind:value={password}>
|
||||
<button on:click={submit}>
|
||||
{#if submitting}
|
||||
<Icon icon="line-md:loading-twotone-loop"/>
|
||||
{:else}
|
||||
{isSignup ? t('welcome.btn-signup') : t('welcome.btn-login')}
|
||||
{/if}
|
||||
</button>
|
||||
<Turnstile siteKey={TURNSTILE_SITE_KEY} bind:reset={turnstileReset}
|
||||
on:turnstile-callback={e => console.log(turnstile = e.detail.token)}
|
||||
on:turnstile-error={_ => console.log(error = t("welcome.turnstile-error"))}
|
||||
on:turnstile-expired={_ => window.location.reload()}
|
||||
on:turnstile-timeout={_ => console.log(error = t('welcome.turnstile-timeout'))} />
|
||||
</div>
|
||||
{:else if state === "verify"}
|
||||
<div class="login-form" transition:slide>
|
||||
<span>{verifyMsg}</span>
|
||||
{#if !submitting}
|
||||
<button on:click={() => state = 'home'} transition:slide>{t('back')}</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="light-pollution">
|
||||
<div class="l1"></div>
|
||||
<div class="l2"></div>
|
||||
<div class="l3"></div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<style lang="sass">
|
||||
@use "../vars"
|
||||
|
||||
.login-form
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: 8px
|
||||
width: calc(100% - 12px)
|
||||
max-width: 300px
|
||||
|
||||
div.clickable
|
||||
display: flex
|
||||
align-items: center
|
||||
|
||||
#home
|
||||
color: vars.$c-main
|
||||
position: relative
|
||||
width: 100%
|
||||
height: 100%
|
||||
padding-left: 100px
|
||||
overflow: hidden
|
||||
background-color: black
|
||||
|
||||
box-sizing: border-box
|
||||
|
||||
display: flex
|
||||
flex-direction: column
|
||||
justify-content: center
|
||||
|
||||
margin-top: -(vars.$nav-height)
|
||||
|
||||
// Content container
|
||||
> div
|
||||
display: flex
|
||||
flex-direction: column
|
||||
align-items: flex-start
|
||||
width: max-content
|
||||
|
||||
// Switching state container
|
||||
> div
|
||||
transition: vars.$transition
|
||||
|
||||
#title
|
||||
font-family: Quicksand, vars.$font
|
||||
user-select: none
|
||||
|
||||
// Gap between text characters
|
||||
letter-spacing: 0.2em
|
||||
margin-top: 0
|
||||
margin-bottom: 32px
|
||||
opacity: 0.9
|
||||
|
||||
.btn-group
|
||||
display: flex
|
||||
gap: 8px
|
||||
|
||||
.light-pollution
|
||||
pointer-events: none
|
||||
opacity: 0.8
|
||||
|
||||
> div
|
||||
position: absolute
|
||||
z-index: 1
|
||||
|
||||
.l1
|
||||
left: -560px
|
||||
top: 90px
|
||||
height: 1130px
|
||||
width: 1500px
|
||||
$color: rgb(158, 110, 230)
|
||||
background: radial-gradient(50% 50% at 50% 50%, rgba($color, 0.28) 0%, rgba(0,0,0,0) 100%)
|
||||
|
||||
.l2
|
||||
left: -200px
|
||||
top: 560px
|
||||
height: 1200px
|
||||
width: 1500px
|
||||
$color: rgb(92, 195, 250)
|
||||
background: radial-gradient(50% 50% at 50% 50%, rgba($color, 0.28) 0%, rgba(0,0,0,0) 100%)
|
||||
|
||||
.l3
|
||||
left: -600px
|
||||
opacity: 0.7
|
||||
top: -630px
|
||||
width: 1500px
|
||||
height: 1000px
|
||||
$color: rgb(230, 110, 156)
|
||||
background: radial-gradient(50% 50% at 50% 50%, rgba($color, 0.28) 0%, rgba(0,0,0,0) 100%)
|
||||
|
||||
@media (max-width: 500px)
|
||||
align-items: center
|
||||
padding-left: 0
|
||||
</style>
|
||||
24
AquaNet/src/vars.sass
Normal file
@@ -0,0 +1,24 @@
|
||||
$font: Quicksand, Inter, LXGW Wenkai, Microsoft YaHei, -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, Avenir, Helvetica, Arial, sans-serif
|
||||
$c-main: #b3c6ff
|
||||
$c-sub: rgba(0, 0, 0, 0.77)
|
||||
$c-good: #b3ffb9
|
||||
$c-darker: #646cff
|
||||
$c-bg: #242424
|
||||
$c-error: #ff6b6b
|
||||
$c-shadow: rgba(0, 0, 0, 0.1)
|
||||
|
||||
$ov-light: rgba(white, 0.04)
|
||||
$ov-lighter: rgba(white, 0.08)
|
||||
$ov-dark: rgba(black, 0.1)
|
||||
$ov-darker: rgba(black, 0.18)
|
||||
|
||||
$nav-height: 4rem
|
||||
$w-mobile: 560px
|
||||
$w-max: 900px
|
||||
|
||||
$grad-special: linear-gradient(90deg, #ffee94, #ffb798, #ffa3e5, #ebff94)
|
||||
|
||||
$border-radius: 12px
|
||||
$gap: 20px
|
||||
|
||||
$transition: all 0.25s
|
||||
3
AquaNet/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
/// <reference types="svelte" />
|
||||
/// <reference types="vite/client" />
|
||||
declare const APP_VERSION: string;
|
||||
7
AquaNet/svelte.config.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
|
||||
|
||||
export default {
|
||||
// Consult https://svelte.dev/docs#compile-time-svelte-preprocess
|
||||
// for more information about preprocessors
|
||||
preprocess: vitePreprocess(),
|
||||
}
|
||||
76
AquaNet/tools/migrate.py
Normal file
@@ -0,0 +1,76 @@
|
||||
import pathlib
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
def extract_and_migrate_sass(file_path):
|
||||
"""Extracts <style lang="sass"> block from a Svelte file, runs sass-migrator on it, and replaces the original style block."""
|
||||
|
||||
with open(file_path, 'r', encoding='utf-8') as file:
|
||||
content = file.read()
|
||||
|
||||
# Regex pattern to match <style lang="sass">...</style>
|
||||
style_pattern = re.compile(r'<style\s+lang\s*=\s*["\']sass["\']>(.*?)</style>', re.DOTALL)
|
||||
|
||||
# Extract all matching style blocks
|
||||
matches = list(style_pattern.finditer(content))
|
||||
|
||||
if not matches:
|
||||
print(f"No <style lang='sass'> block found in {file_path}")
|
||||
return
|
||||
|
||||
updated_content = content
|
||||
|
||||
for match in matches:
|
||||
original_style_block = match.group(0) # The full <style>...</style> block
|
||||
sass_content = match.group(1) # The content inside the <style> block
|
||||
|
||||
# Create a temporary file in the same directory as the .svelte file
|
||||
temp_file_path = pathlib.Path(file_path).parent / f"{pathlib.Path(file_path).stem}_temp.sass"
|
||||
|
||||
# Write the SASS content to the temporary file
|
||||
with open(temp_file_path, 'w', encoding='utf-8') as temp_file:
|
||||
temp_file.write(sass_content)
|
||||
|
||||
# Run the sass-migrator on the temporary file
|
||||
try:
|
||||
subprocess.run(['sass-migrator', 'module', str(temp_file_path)], check=True)
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"Error while running sass-migrator on {file_path}: {e}")
|
||||
continue
|
||||
|
||||
# Read back the migrated content
|
||||
with open(temp_file_path, 'r', encoding='utf-8') as temp_file:
|
||||
migrated_sass_content = temp_file.read()
|
||||
|
||||
# Create the new <style> block with the migrated SASS content
|
||||
new_style_block = f'<style lang="sass">{migrated_sass_content}</style>'
|
||||
|
||||
# Replace the original style block with the new one
|
||||
updated_content = updated_content.replace(original_style_block, new_style_block)
|
||||
|
||||
# Remove the temporary file
|
||||
temp_file_path.unlink()
|
||||
|
||||
# Write the updated content back to the original file
|
||||
with open(file_path, 'w', encoding='utf-8') as file:
|
||||
file.write(updated_content)
|
||||
|
||||
print(f"Updated {file_path}")
|
||||
|
||||
def process_svelte_files(directory):
|
||||
"""Recursively processes all .svelte files in the given directory."""
|
||||
|
||||
svelte_files = pathlib.Path(directory).rglob("*.svelte")
|
||||
|
||||
for svelte_file in svelte_files:
|
||||
extract_and_migrate_sass(svelte_file)
|
||||
|
||||
def main():
|
||||
"""Main function to process all .svelte files in the current directory."""
|
||||
|
||||
current_directory = pathlib.Path(__file__).parent
|
||||
process_svelte_files(current_directory)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
20
AquaNet/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"extends": "@tsconfig/svelte/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"resolveJsonModule": true,
|
||||
/**
|
||||
* Typecheck JS in `.svelte` and `.js` files by default.
|
||||
* Disable checkJs if you'd like to use dynamic types in JS.
|
||||
* Note that setting allowJs false does not prevent the use
|
||||
* of JS in `.svelte` files.
|
||||
*/
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"isolatedModules": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
9
AquaNet/tsconfig.node.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler"
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||