194 Commits

Author SHA1 Message Date
1ba79debb2 chore(deps): update dependency postcss to v8.4.38 2024-03-23 21:00:09 +00:00
2fedf0e6ff add shortened name for site name
All checks were successful
deploy / deploy (push) Successful in 1m8s
2024-02-01 03:20:44 +00:00
a14ff2f343 Merge branch 'master' of https://git.fascinated.cc/Fascinated/scoresaber-reloaded-v2
All checks were successful
deploy / deploy (push) Successful in 1m2s
2024-02-01 02:23:56 +00:00
be9328bab5 why was this disabled? 2024-02-01 02:23:55 +00:00
Lee
73046d46d4 Merge pull request 'fix(deps): update dependency sharp to ^0.33.0' (#42) from renovate/sharp-0.x into master
All checks were successful
deploy / deploy (push) Successful in 33s
Reviewed-on: #42
2024-02-01 02:11:11 +00:00
Lee
6fcb561085 Merge pull request 'fix(deps): update dependency lucide-react to ^0.320.0' (#41) from renovate/lucide-react-0.x into master
Some checks failed
deploy / deploy (push) Failing after 4s
Reviewed-on: #41
2024-02-01 02:11:04 +00:00
Lee
fac75f1e6a Merge pull request 'chore(deps): update dependency eslint-config-next to v14.1.0' (#43) from renovate/nextjs-monorepo into master
Some checks failed
deploy / deploy (push) Failing after 5s
Reviewed-on: #43
2024-02-01 02:10:56 +00:00
Lee
9b5d38896d Merge pull request 'fix(deps): update dependency date-fns to v3' (#44) from renovate/date-fns-3.x into master
Some checks failed
deploy / deploy (push) Has been cancelled
Reviewed-on: #44
2024-02-01 02:10:48 +00:00
1b42f9f6d9 fix(deps): update dependency date-fns to v3 2024-02-01 02:09:43 +00:00
136fc06469 fix(deps): update dependency sharp to ^0.33.0 2024-02-01 02:09:38 +00:00
e6f2e6a03d fix(deps): update dependency lucide-react to ^0.320.0 2024-02-01 02:09:29 +00:00
c0c8b625be chore(deps): update dependency eslint-config-next to v14.1.0 2024-02-01 02:09:25 +00:00
ae482a48c0 bump nextjs
All checks were successful
deploy / deploy (push) Successful in 1m35s
2024-02-01 01:52:57 +00:00
75bf876fb6 bump depends
All checks were successful
deploy / deploy (push) Successful in 1m31s
2024-02-01 01:51:03 +00:00
Lee
2d9de44fa9 Merge pull request 'fix(deps): update dependency react-toastify to v10' (#45) from renovate/react-toastify-10.x into master
Some checks failed
deploy / deploy (push) Failing after 46s
Reviewed-on: #45
2024-02-01 01:47:11 +00:00
8c9a5de93b i am a goof ball
All checks were successful
deploy / deploy (push) Successful in 1m3s
2024-02-01 01:45:07 +00:00
56f7255918 sentry stuff
All checks were successful
deploy / deploy (push) Successful in 1m14s
2024-02-01 01:41:10 +00:00
f58ea539f9 remove sentry
Some checks failed
deploy / deploy (push) Failing after 27s
2024-02-01 01:39:37 +00:00
0f70a50bd4 remove analytics page
Some checks failed
deploy / deploy (push) Failing after 21s
2024-02-01 01:37:52 +00:00
738a95180f fix(deps): update dependency react-toastify to v10 2024-01-14 18:04:03 +00:00
Lee
4e3ba2d0c7 Update src/components/Footer.tsx
Some checks failed
deploy / deploy (push) Has been cancelled
2023-12-07 16:33:06 +00:00
9e73ff3937 always show country flags in ranking pages
All checks were successful
deploy / deploy (push) Successful in 2m3s
2023-11-27 01:03:27 +00:00
3b7f458d5c update how player ranks are shown in countries
All checks were successful
deploy / deploy (push) Successful in 1m13s
2023-11-27 00:57:57 +00:00
d769b0d15e remove settings on navbar (for now)
All checks were successful
deploy / deploy (push) Successful in 1m10s
2023-11-26 14:31:18 +00:00
cf75dfb06d make padding slighly less on cards
All checks were successful
deploy / deploy (push) Successful in 2m10s
2023-11-26 01:56:05 +00:00
41090360e1 try/catch on sitemap
All checks were successful
deploy / deploy (push) Successful in 1m12s
2023-11-24 21:02:58 +00:00
b5629c0169 fix button alignment on score
Some checks failed
deploy / deploy (push) Failing after 36s
2023-11-24 21:00:50 +00:00
4c0775b000 fix(ssr): still show the youtube button even if the beatsaver map doesn't exist anymore
All checks were successful
deploy / deploy (push) Successful in 1m14s
2023-11-24 20:50:51 +00:00
407bcc866b defer beatsaver map data loading & fix it being fucked
All checks were successful
deploy / deploy (push) Successful in 1m15s
2023-11-24 20:43:23 +00:00
b74b22c0b6 Merge branch 'master' of https://git.fascinated.cc/Fascinated/scoresaber-reloaded-v2
All checks were successful
deploy / deploy (push) Successful in 1m14s
2023-11-24 19:29:40 +00:00
0f7b28ca02 only get ids for beatsaver maps 2023-11-24 19:29:39 +00:00
Lee
d5afdbde69 Merge pull request 'fix(deps): update dependency lucide-react to ^0.293.0' (#40) from renovate/lucide-react-0.x into master
All checks were successful
deploy / deploy (push) Successful in 1m40s
Reviewed-on: #40
2023-11-24 13:42:57 +00:00
31d37feb8e fix(deps): update dependency lucide-react to ^0.293.0 2023-11-24 13:03:53 +00:00
b3c3be4b7c make loading beatsaver map hashes way faster
All checks were successful
deploy / deploy (push) Successful in 2m9s
2023-11-21 12:27:47 +00:00
Lee
14f67af125 Merge pull request 'chore(deps): update nextjs monorepo to v14.0.3' (#39) from renovate/nextjs-monorepo into master
All checks were successful
deploy / deploy (push) Successful in 2m28s
Reviewed-on: #39
2023-11-19 10:02:34 +00:00
bb45a4bd6b chore(deps): update nextjs monorepo to v14.0.3 2023-11-16 19:02:14 +00:00
65e8df76a2 feat(ssr): add tooltip to youtube button
All checks were successful
deploy / deploy (push) Successful in 1m12s
2023-11-16 10:21:08 +00:00
925e423955 feat(ssr): add song sub name to youtube link
All checks were successful
deploy / deploy (push) Successful in 1m11s
2023-11-16 10:19:46 +00:00
ef7c6f4878 add a youtube link button to songs
All checks were successful
deploy / deploy (push) Successful in 1m17s
2023-11-16 10:13:20 +00:00
fd7cbf73a7 part 2
All checks were successful
deploy / deploy (push) Successful in 1m17s
2023-11-16 09:54:17 +00:00
a793847b91 fix(overlay): fix imports
Some checks failed
deploy / deploy (push) Failing after 31s
2023-11-16 09:53:04 +00:00
8c50b484cf fix(overlay): reset state when losing connection to the data provider
Some checks failed
deploy / deploy (push) Failing after 18s
2023-11-16 09:51:40 +00:00
3c5b5be02f fix(overlay): set paused state to false when loading into a song
Some checks failed
deploy / deploy (push) Failing after 39s
2023-11-16 09:49:09 +00:00
da701cf046 7
All checks were successful
deploy / deploy (push) Successful in 1m9s
2023-11-13 07:39:54 +00:00
443b4ce2f7 7
Some checks failed
deploy / deploy (push) Failing after 1m13s
2023-11-13 07:20:04 +00:00
cbcaaa2d8c 7
Some checks failed
deploy / deploy (push) Failing after 33s
2023-11-13 06:41:32 +00:00
4a03b0c97f 7
Some checks failed
deploy / deploy (push) Failing after 35s
2023-11-13 06:36:43 +00:00
dbfc6a93d0 7
Some checks failed
deploy / deploy (push) Failing after 34s
2023-11-13 06:35:08 +00:00
992b97a35d 7
Some checks failed
deploy / deploy (push) Failing after 56s
2023-11-13 06:32:30 +00:00
d1b0d85ecf 77
Some checks failed
deploy / deploy (push) Failing after 35s
2023-11-13 06:24:39 +00:00
7e730ed0c9 7
Some checks failed
deploy / deploy (push) Failing after 35s
2023-11-13 06:22:59 +00:00
bdb2ffc7ba 7
Some checks failed
deploy / deploy (push) Failing after 33s
2023-11-13 06:00:14 +00:00
95a9e103eb 7
Some checks failed
deploy / deploy (push) Failing after 34s
2023-11-13 05:57:46 +00:00
5e817186de many smarts
Some checks failed
deploy / deploy (push) Failing after 33s
2023-11-13 05:55:38 +00:00
75afdfed7d add infiscal
Some checks failed
deploy / deploy (push) Has been cancelled
2023-11-13 05:42:06 +00:00
9c2bf54426 Merge branch 'master' of https://git.fascinated.cc/Fascinated/scoresaber-reloaded-v2
Some checks failed
deploy / deploy (push) Failing after 19s
2023-11-13 03:52:58 +00:00
Lee
0c9fc07e83 Delete .env
Some checks failed
deploy / deploy (push) Failing after 20s
2023-11-13 03:52:39 +00:00
24c787969c sentry 2023-11-13 03:52:20 +00:00
000812b2e5 sentry
Some checks failed
deploy / deploy (push) Failing after 20s
2023-11-13 03:46:08 +00:00
c22dabeb9b sentry stuff
Some checks failed
deploy / deploy (push) Failing after 18s
2023-11-13 03:43:15 +00:00
7a31e158ea re-enable sentry
Some checks failed
deploy / deploy (push) Failing after 19s
2023-11-13 03:41:29 +00:00
b42ba1afdd feat(ssr): add update last updated to analytics embed
All checks were successful
deploy / deploy (push) Successful in 2m10s
2023-11-13 01:10:20 +00:00
426a2b5a2f fix(ssr): fix no players msg showing if there's no search
All checks were successful
deploy / deploy (push) Successful in 1m5s
2023-11-09 20:01:28 +00:00
350fe875fe Merge branch 'master' of https://git.fascinated.cc/Fascinated/scoresaber-reloaded-v2
All checks were successful
deploy / deploy (push) Successful in 1m6s
2023-11-09 19:58:18 +00:00
13b814700f feat(ssr): add no players found msg on search page 2023-11-09 19:58:17 +00:00
Lee
96496edfdf Merge pull request 'chore(deps): update nextjs monorepo to v14.0.2' (#38) from renovate/nextjs-monorepo into master
All checks were successful
deploy / deploy (push) Successful in 1m29s
Reviewed-on: #38
2023-11-09 19:07:41 +00:00
0797bf8523 chore(deps): update nextjs monorepo to v14.0.2 2023-11-09 19:01:59 +00:00
5e4dcf1b37 change(ssr): removed mapped by text and just put mapper name
All checks were successful
deploy / deploy (push) Successful in 1m3s
2023-11-09 19:01:29 +00:00
0ffb3e341d fix(ssr): fix showing star count on unranked leaderboards
All checks were successful
deploy / deploy (push) Successful in 1m31s
2023-11-09 18:57:25 +00:00
b60d713d3c feat(ssr): open beatsaver page in new tab
All checks were successful
deploy / deploy (push) Successful in 1m2s
2023-11-09 00:03:20 +00:00
1a5493be69 change(ssr): re-connect to redis after 30s
All checks were successful
deploy / deploy (push) Successful in 1m3s
2023-11-08 23:59:36 +00:00
5822aa70bf change(ssr): add connected to redis message
All checks were successful
deploy / deploy (push) Successful in 1m2s
2023-11-08 23:26:57 +00:00
016b958546 fix(ssr): don't re-connect to redis if we're already connected 2023-11-08 23:25:14 +00:00
e6b169f3fc move beatsaver map caching to redis
All checks were successful
deploy / deploy (push) Successful in 1m35s
2023-11-08 23:17:32 +00:00
af707a8c79 fix(ssr): only use 1 network request to fetch map ids
All checks were successful
deploy / deploy (push) Successful in 58s
2023-11-08 22:24:38 +00:00
3dd2fc48ad fix(ssr): fix button colors
All checks were successful
deploy / deploy (push) Successful in 1m0s
2023-11-08 22:17:40 +00:00
b9472ce982 feat(ssr): add map and bsr button
All checks were successful
deploy / deploy (push) Successful in 1m3s
2023-11-08 22:13:20 +00:00
c3889a4d5a fix(ssr): fix type on viewport
All checks were successful
deploy / deploy (push) Successful in 1m12s
2023-11-08 19:50:56 +00:00
736f286d66 fix(ssr): attempt to fix theme flashing
All checks were successful
deploy / deploy (push) Successful in 1m2s
2023-11-08 19:43:19 +00:00
3341f07136 fix href pagination
All checks were successful
deploy / deploy (push) Successful in 59s
2023-11-08 12:06:52 +00:00
662999f65b fix(ssr): fix pagination
All checks were successful
deploy / deploy (push) Successful in 1m1s
2023-11-08 11:58:36 +00:00
3b2e726fa1 attempt to see if optimizePackageImports does anything
All checks were successful
deploy / deploy (push) Successful in 1m6s
2023-11-08 11:53:30 +00:00
a5abcb1e23 testinggggggggggggggg
All checks were successful
deploy / deploy (push) Successful in 1m36s
2023-11-08 11:49:19 +00:00
fb0e8a7ea1 lazy load some components
All checks were successful
deploy / deploy (push) Successful in 59s
2023-11-08 09:55:20 +00:00
26490bbad4 disable sentry (testing)
All checks were successful
deploy / deploy (push) Successful in 59s
2023-11-08 09:28:18 +00:00
ddf0b61282 test
All checks were successful
deploy / deploy (push) Successful in 1m11s
2023-11-08 09:25:25 +00:00
f2801f2ae7 feat(ssr): server side render parts of the navbar
All checks were successful
deploy / deploy (push) Successful in 1m14s
2023-11-08 09:15:42 +00:00
9637993471 fix(ssr): fix big ahh button size
All checks were successful
deploy / deploy (push) Successful in 1m19s
2023-11-08 08:52:35 +00:00
6ec834ddc5 fix(ssr): add screen reader labels to buttons on player page
All checks were successful
deploy / deploy (push) Successful in 1m20s
2023-11-08 08:49:33 +00:00
e40c78e9b9 fix(ssr): remove a tag on button 2023-11-08 08:49:15 +00:00
fb38ac69df fix(ssr): fix pagination being broken on inital player page load
All checks were successful
deploy / deploy (push) Successful in 1m44s
2023-11-08 08:40:49 +00:00
0a8878cdac feat(ssr): server load inital page of scores
All checks were successful
deploy / deploy (push) Successful in 1m13s
2023-11-08 08:11:48 +00:00
8e5aeadce0 fix(ssr): make the player page less jarring when initally loading
All checks were successful
deploy / deploy (push) Successful in 1m16s
2023-11-08 08:01:00 +00:00
c251239e45 feat(ssr): server side render some things to speed up page loads
All checks were successful
deploy / deploy (push) Successful in 1m12s
2023-11-08 07:41:07 +00:00
3a6312510a make package installs quiet
All checks were successful
deploy / deploy (push) Successful in 2m5s
2023-11-08 06:38:07 +00:00
c84f5e1dd4 haha ball
All checks were successful
deploy / deploy (push) Successful in 37s
2023-11-08 06:20:11 +00:00
928e1df444 switch to pnpm
Some checks failed
deploy / deploy (push) Failing after 13s
2023-11-08 06:19:10 +00:00
5f6c0bbe22 fix(ssr): fix 404 page
All checks were successful
deploy / deploy (push) Successful in 2m27s
2023-11-08 06:03:44 +00:00
fb0629a58f more sitemap stuff
All checks were successful
deploy / deploy (push) Successful in 1m14s
2023-11-08 00:49:43 +00:00
7259ffb3de add comments in overlay page 2023-11-08 00:29:22 +00:00
5de33019d8 fix(overlay): redirect to builder if no config was provided
All checks were successful
deploy / deploy (push) Successful in 1m3s
2023-11-08 00:28:28 +00:00
bfe884a9e6 feat(seo): more keywords
All checks were successful
deploy / deploy (push) Successful in 1m2s
2023-11-08 00:12:05 +00:00
2904e4c0c9 feat(sitemap): add additional data (priority, change frequency and last modified)
All checks were successful
deploy / deploy (push) Successful in 1m2s
2023-11-07 23:58:24 +00:00
72cf612b76 change(ssr): update search page
All checks were successful
deploy / deploy (push) Successful in 1m3s
2023-11-07 23:23:50 +00:00
4bf874453e bing
All checks were successful
deploy / deploy (push) Successful in 1m2s
2023-11-07 23:19:03 +00:00
15aeaa69d5 fix(docker): move sitemap build
All checks were successful
deploy / deploy (push) Successful in 1m2s
2023-11-07 23:11:59 +00:00
b24f1db3c1 fix(ssr): copy ssrSettings.json in build step
All checks were successful
deploy / deploy (push) Successful in 1m2s
2023-11-07 23:10:15 +00:00
0529cf3492 fix(ssr): copy next-sitemap.config.js in dockerfile
All checks were successful
deploy / deploy (push) Successful in 1m12s
2023-11-07 23:08:23 +00:00
b37d7cd621 Merge branch 'master' of https://git.fascinated.cc/Fascinated/scoresaber-reloaded-v2
All checks were successful
deploy / deploy (push) Successful in 2m31s
2023-11-07 23:05:12 +00:00
41d1d5f1d7 feat(ssr): add sitemap generator 2023-11-07 23:05:11 +00:00
Lee
ea89e8cb85 Merge pull request 'chore(deps): update dependency @types/websocket to v1.0.9' (#35) from renovate/websocket-1.x-lockfile into master
All checks were successful
deploy / deploy (push) Successful in 58s
Reviewed-on: #35
2023-11-07 22:10:16 +00:00
Lee
f56ba5269a Merge pull request 'chore(deps): update dependency @types/node to v20.9.0' (#37) from renovate/node-20.x-lockfile into master
All checks were successful
deploy / deploy (push) Successful in 58s
Reviewed-on: #37
2023-11-07 22:03:01 +00:00
43b762f756 chore(deps): update dependency @types/node to v20.9.0 2023-11-07 22:01:28 +00:00
4baa1ca956 chore(deps): update dependency @types/websocket to v1.0.9 2023-11-07 22:01:24 +00:00
Lee
4084891c73 Merge pull request 'chore(deps): update react monorepo to v18.2.37' (#36) from renovate/react-monorepo into master
All checks were successful
deploy / deploy (push) Successful in 58s
Reviewed-on: #36
2023-11-07 21:43:12 +00:00
Lee
4864b8bd5b Merge pull request 'chore(deps): update dependency @types/node-fetch-cache to v3.0.4' (#34) from renovate/node-fetch-cache-3.x-lockfile into master
All checks were successful
deploy / deploy (push) Successful in 2m47s
Reviewed-on: #34
2023-11-07 21:06:58 +00:00
2187d37e3e chore(deps): update react monorepo to v18.2.37 2023-11-07 21:01:56 +00:00
6bd409fbda chore(deps): update dependency @types/node-fetch-cache to v3.0.4 2023-11-07 12:02:54 +00:00
4b966aaaae fix(overlay): make leaderboard logo priority loaded
All checks were successful
deploy / deploy (push) Successful in 1m4s
2023-11-06 19:45:46 +00:00
5e8737d7fa fix(overlay): make avatar priority loaded
All checks were successful
deploy / deploy (push) Successful in 57s
2023-11-06 19:39:23 +00:00
cefaa734fd feat(overlay): add a check for ip addr and account id
All checks were successful
deploy / deploy (push) Successful in 57s
2023-11-06 19:24:07 +00:00
226716b555 change(overlay): make images more rounded
All checks were successful
deploy / deploy (push) Successful in 56s
2023-11-06 19:20:04 +00:00
d7c6b24fac change(overlay): remove song sub name
All checks were successful
deploy / deploy (push) Successful in 57s
2023-11-06 19:18:14 +00:00
36c723e846 change(overlay): remove "Combo:" on score stats
All checks were successful
deploy / deploy (push) Successful in 58s
2023-11-06 18:55:42 +00:00
cfb7d1f15d revert(ssr): revert locale stuff
All checks were successful
deploy / deploy (push) Successful in 58s
2023-11-06 18:52:03 +00:00
cfaca9fb40 feat(ssr): format numbers using browser locale
All checks were successful
deploy / deploy (push) Successful in 58s
2023-11-06 18:43:21 +00:00
e16727713c fix(overlay): remove debug
All checks were successful
deploy / deploy (push) Successful in 59s
2023-11-06 18:36:21 +00:00
638dc528ad change(overlay): remove bold on pp
All checks were successful
deploy / deploy (push) Successful in 58s
2023-11-06 18:25:57 +00:00
cff615424d change(overlay): make mapper name larger and slightly grey 2023-11-06 18:25:40 +00:00
1a7c7751b6 Merge branch 'master' of https://git.fascinated.cc/Fascinated/scoresaber-reloaded-v2
All checks were successful
deploy / deploy (push) Successful in 58s
2023-11-06 17:40:55 +00:00
be998f068c add build time 2023-11-06 17:40:54 +00:00
Lee
8e0a6a736e Merge pull request 'chore(deps): update dependency @types/react to v18.2.36' (#33) from renovate/react-monorepo into master
All checks were successful
deploy / deploy (push) Successful in 2m7s
Reviewed-on: #33
2023-11-06 14:13:29 +00:00
95965aa391 chore(deps): update dependency @types/react to v18.2.36 2023-11-06 12:04:55 +00:00
2e4842efd4 fix(overlay): increase size of song info
All checks were successful
deploy / deploy (push) Successful in 58s
2023-11-05 23:21:35 +00:00
3e5f141938 feat(overlay): add leaderboard toggle for BL and SS
All checks were successful
deploy / deploy (push) Successful in 58s
2023-11-05 23:06:28 +00:00
fb2b72875f feat(overlay): add rank colors to score stats
All checks were successful
deploy / deploy (push) Successful in 57s
2023-11-05 22:30:33 +00:00
fadfdee316 feat(overlay): add song sub name to song info 2023-11-05 22:22:02 +00:00
1485083d60 feat(overlay): add "x" to the end of combo
All checks were successful
deploy / deploy (push) Successful in 58s
2023-11-05 22:19:32 +00:00
2fbc463264 feat(overlay): make raw score bold 2023-11-05 22:18:23 +00:00
e88bfaab89 feat(overlay): reduced spacee between score stats and player stats
All checks were successful
deploy / deploy (push) Successful in 1m3s
2023-11-05 22:17:55 +00:00
1b6715318a fix(overlay): fix using wrong key for accountId
All checks were successful
deploy / deploy (push) Successful in 58s
2023-11-05 21:21:25 +00:00
6b0f1bf5ce fix(overlay): infinite loading when show player stats is off
All checks were successful
deploy / deploy (push) Successful in 58s
2023-11-05 21:19:02 +00:00
22dfb56167 make build id clickable
All checks were successful
deploy / deploy (push) Successful in 59s
2023-11-05 21:16:43 +00:00
e9de4fa258 fix(overlay): fix score stats label
All checks were successful
deploy / deploy (push) Successful in 58s
2023-11-05 21:13:38 +00:00
c9a6703d94 fix(overlay): re-add score stats toggle
All checks were successful
deploy / deploy (push) Successful in 59s
2023-11-05 21:12:08 +00:00
a49fa9dbb6 feat(overlay): add HttpSiraStatus requirement notice
All checks were successful
deploy / deploy (push) Successful in 58s
2023-11-05 21:07:52 +00:00
11826faa9a feat(overlay): dont fetch player data if the settings is off
All checks were successful
deploy / deploy (push) Successful in 58s
2023-11-05 21:04:07 +00:00
d574d701f6 add na cdn url for beatsaver
All checks were successful
deploy / deploy (push) Successful in 1m2s
2023-11-05 20:59:57 +00:00
666584e022 fix settings not working
Some checks failed
deploy / deploy (push) Failing after 5s
2023-11-05 20:58:54 +00:00
1792648e8d add overlay
Some checks failed
deploy / deploy (push) Has been cancelled
2023-11-05 20:56:19 +00:00
d2df95381c Update leaderboard styling
All checks were successful
deploy / deploy (push) Successful in 56s
2023-11-05 14:41:43 +00:00
44fb21ee82 use pp blue on score
All checks were successful
deploy / deploy (push) Successful in 56s
2023-11-05 14:39:46 +00:00
383923a768 update leaderboard styles
All checks were successful
deploy / deploy (push) Successful in 56s
2023-11-05 14:36:15 +00:00
1aa8630db0 fix mobile support on leaderboards
All checks were successful
deploy / deploy (push) Successful in 56s
2023-11-05 14:31:09 +00:00
0123541095 Update Leaderboard component styles
All checks were successful
deploy / deploy (push) Successful in 55s
2023-11-05 14:21:15 +00:00
4981ce5d37 fix text color 2023-11-05 14:20:55 +00:00
ce4ec291c6 make leaderboard page look nicer
All checks were successful
deploy / deploy (push) Successful in 56s
2023-11-05 14:16:41 +00:00
57c2fe1301 prep work for themes 2023-11-05 14:16:23 +00:00
1ab07709ac make icons larger on navbar
All checks were successful
deploy / deploy (push) Successful in 56s
2023-11-05 13:51:31 +00:00
40d5bcf86d change divide color again 2023-11-05 13:50:21 +00:00
ad162cbe3f change divider color
All checks were successful
deploy / deploy (push) Successful in 55s
2023-11-05 13:47:07 +00:00
832f4eebe5 fix rank format on player graph
All checks were successful
deploy / deploy (push) Successful in 56s
2023-11-05 13:42:54 +00:00
3967ea76e0 make grid lines more visible on graphs 2023-11-05 13:40:51 +00:00
59c3b7a421 Merge branch 'master' of https://git.fascinated.cc/Fascinated/scoresaber-reloaded-v2
All checks were successful
deploy / deploy (push) Successful in 57s
2023-11-05 13:31:04 +00:00
f124fb1a57 tailwind stuff 2023-11-05 13:31:02 +00:00
e7ed757194 change button colors for add and remove friend 2023-11-05 13:30:58 +00:00
db837d6d90 make search page less ugly 2023-11-05 13:30:28 +00:00
afe17cd982 cleanup navbar 2023-11-05 13:30:18 +00:00
ec2afd8811 fix css on on loading card for ranking pages 2023-11-05 13:30:00 +00:00
65cfe58557 fix navbar on mobile 2023-11-05 13:29:34 +00:00
Lee
092d5a5d24 Merge pull request 'chore(deps): update dependency @types/react to v18.2.35' (#32) from renovate/react-monorepo into master
All checks were successful
deploy / deploy (push) Successful in 56s
Reviewed-on: #32
2023-11-05 12:56:27 +00:00
e5913392b5 chore(deps): update dependency @types/react to v18.2.35 2023-11-05 10:02:38 +00:00
606e129429 add tooltip to friends button
All checks were successful
deploy / deploy (push) Successful in 56s
2023-11-05 03:16:22 +00:00
87edf0e3e3 theme changes
All checks were successful
deploy / deploy (push) Successful in 3m9s
2023-11-05 02:55:57 +00:00
Lee
27b4eb38ea Merge pull request 'chore(deps): update dependency @types/react to v18.2.34' (#28) from renovate/react-monorepo into master
All checks were successful
deploy / deploy (push) Successful in 57s
Reviewed-on: #28
2023-11-04 22:43:41 +00:00
ffd821e6d2 chore(deps): update dependency @types/react to v18.2.34 2023-11-04 22:02:49 +00:00
Lee
5148a76cf2 Merge pull request 'fix(deps): update dependency lucide-react to ^0.292.0' (#30) from renovate/lucide-react-0.x into master
All checks were successful
deploy / deploy (push) Successful in 3m4s
Reviewed-on: #30
2023-11-04 21:34:36 +00:00
Lee
f37afc849c Merge pull request 'chore(deps): update dependency eslint to v8.53.0' (#31) from renovate/eslint-8.x-lockfile into master
All checks were successful
deploy / deploy (push) Successful in 57s
Reviewed-on: #31
2023-11-04 21:32:59 +00:00
Lee
f2ea9ae1ca Merge pull request 'fix(deps): update dependency zustand to v4.4.6' (#29) from renovate/zustand-4.x-lockfile into master
All checks were successful
deploy / deploy (push) Successful in 3m8s
Reviewed-on: #29
2023-11-04 21:29:09 +00:00
69d3e34556 chore(deps): update dependency eslint to v8.53.0 2023-11-04 00:02:26 +00:00
d80659e884 fix(deps): update dependency lucide-react to ^0.292.0 2023-11-03 08:03:57 +00:00
25307a72ef fix(deps): update dependency zustand to v4.4.6 2023-11-03 00:02:18 +00:00
3e6d04a13b revert zustand update
Some checks failed
deploy / deploy (push) Failing after 23s
2023-10-31 17:53:29 +00:00
24cf9bfb14 make navbar use card
Some checks failed
deploy / deploy (push) Failing after 22s
2023-10-31 17:47:44 +00:00
Lee
06a908b79b Merge pull request 'fix(deps): update dependency lucide-react to ^0.291.0' (#24) from renovate/lucide-react-0.x into master
Some checks failed
deploy / deploy (push) Failing after 23s
Reviewed-on: #24
2023-10-31 15:44:27 +00:00
Lee
f8eb17b06c Merge pull request 'fix(deps): update dependency zustand to v4.4.5' (#27) from renovate/zustand-4.x-lockfile into master
All checks were successful
deploy / deploy (push) Successful in 55s
Reviewed-on: #27
2023-10-31 15:41:06 +00:00
7aeeea79ba fix(deps): update dependency zustand to v4.4.5 2023-10-31 15:02:21 +00:00
c3835d963d fix(deps): update dependency lucide-react to ^0.291.0 2023-10-31 13:02:20 +00:00
Lee
ab6ba10cb6 Merge pull request 'chore(deps): update nextjs monorepo to v14.0.1' (#23) from renovate/nextjs-monorepo into master
All checks were successful
deploy / deploy (push) Successful in 1m47s
Reviewed-on: #23
2023-10-31 12:17:58 +00:00
aa2ae06a3e chore(deps): update nextjs monorepo to v14.0.1 2023-10-31 12:02:26 +00:00
Lee
00864515a6 Merge pull request 'chore(deps): update dependency @types/node to v20.8.10' (#25) from renovate/node-20.x-lockfile into master
All checks were successful
deploy / deploy (push) Successful in 58s
Reviewed-on: #25
2023-10-31 11:45:08 +00:00
Lee
61a0252c9f Merge pull request 'fix(deps): update dependency @sentry/nextjs to v7.77.0' (#26) from renovate/sentry-javascript-monorepo into master
All checks were successful
deploy / deploy (push) Successful in 1m53s
Reviewed-on: #26
2023-10-31 11:38:13 +00:00
12873429b8 fix(deps): update dependency @sentry/nextjs to v7.77.0 2023-10-31 11:02:35 +00:00
fe7d31a6a0 chore(deps): update dependency @types/node to v20.8.10 2023-10-31 09:02:17 +00:00
115 changed files with 7661 additions and 7981 deletions

View File

@ -1 +1,4 @@
SENTRY_AUTH_TOKEN=hi
INFISICAL_TOKEN=hi
# Redis
REDIS_URL=redis://localhost:6379/0

5
.eslintrc.js Normal file
View File

@ -0,0 +1,5 @@
const path = require("path");
module.exports = {
extends: ["next/core-web-vitals"],
};

View File

@ -1,3 +0,0 @@
{
"extends": "next/core-web-vitals"
}

8
.gitignore vendored
View File

@ -5,8 +5,9 @@
/.pnp
.pnp.js
# yarn
# lock files
yarn.lock
package-lock.json
# testing
/coverage
@ -42,3 +43,8 @@ next-env.d.ts
# Webpack bundle analyzer
analyze
# Sitemap
public/sitemap*
.env

5
.infisical.json Normal file
View File

@ -0,0 +1,5 @@
{
"workspaceId": "6551ad1ded9edd83540488e0",
"defaultEnvironment": "",
"gitBranchToEnvironmentMapping": null
}

View File

@ -1,11 +1,11 @@
FROM fascinated/docker-images:node-latest AS base
FROM fascinated/docker-images:node-pnpm-latest AS base
# Install depends
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json* package-lock.yaml* ./
RUN npm install --frozen-lockfile --quiet
COPY package.json* pnpm-lock.yaml* ./
RUN pnpm install --frozen-lockfile --quiet
# Build from source
FROM base AS builder
@ -17,7 +17,14 @@ ENV NEXT_TELEMETRY_DISABLED 1
ARG GIT_REV
ENV GIT_REV ${GIT_REV}
RUN npm run build
# ARG REDIS_URL
ENV REDIS_URL redis://:bigtitsyes7@10.0.0.203:30004
# Build the app
RUN pnpm run build
# Generate sitemap
RUN pnpm run generate-sitemap
# Run the app
FROM base AS runner
@ -42,4 +49,4 @@ USER nextjs
EXPOSE 80
ENV HOSTNAME "0.0.0.0"
ENV PORT 80
CMD ["npm", "run", "start"]
CMD ["pnpm", "start"]

74
next-sitemap.config.js Normal file
View File

@ -0,0 +1,74 @@
const ssrSettings = require("./src/ssrSettings");
const { getCodeList } = require("country-list");
const SS_API_URL = ssrSettings.proxy + "/https://scoresaber.com/api";
const SS_GET_PLAYERS_URL = SS_API_URL + "/players?page={}";
// todo: cache this on a file somehow?
async function getTopPlayers() {
console.log("Fetching top players...");
const players = [];
const pagesToFetch = 10;
for (let i = 0; i < pagesToFetch; i++) {
console.log(`Fetching page ${i + 1} of ${pagesToFetch}...`);
try {
const response = await fetch(SS_GET_PLAYERS_URL.replace("{}", i));
const data = await response.json();
players.push(...data.players);
} catch (e) {
console.log(`Error fetching page ${i + 1} of ${pagesToFetch}: ${e}`);
}
}
console.log("Done fetching top players.");
return players;
}
const additionalData = {
priority: 0.5,
changefreq: "monthly",
lastmod: new Date().toISOString(),
};
/** @type {import('next-sitemap').IConfig} */
module.exports = {
siteUrl: ssrSettings.siteUrl,
generateRobotsTxt: true,
additionalPaths: async (config) => {
const paths = [];
// Add the top 50 global ranking pages
for (let i = 0; i < 50; i++) {
paths.push({
loc: `/ranking/global/${i + 1}`,
...additionalData,
});
}
// Add the top 50 pages for all countries
const countries = Object.keys(getCodeList());
for (const country of countries) {
for (let i = 0; i < 50; i++) {
paths.push({
loc: `/ranking/country/${country}/${i + 1}`,
...additionalData,
});
}
}
const sortTypes = ["top", "recent"];
// Add top players
const players = await getTopPlayers();
for (const sortType of sortTypes) {
for (const player of players) {
for (let i = 0; i < 5; i++) {
paths.push({
loc: `/player/${player.id}/${sortType}/${i + 1}`,
...additionalData,
});
}
}
}
return paths;
},
};

View File

@ -1,75 +1,57 @@
const nextBuildId = require("next-build-id");
const withBundleAnalyzer = require("@next/bundle-analyzer")({
enabled: false,
});
const withBundleAnalyzer = require("@next/bundle-analyzer")({ enabled: false });
// Define remote patterns for images
const remotePatterns = [
{ protocol: "https", hostname: "cdn.fascinated.cc", pathname: "/**" },
{ protocol: "https", hostname: "cdn.scoresaber.com", pathname: "/**" },
{ protocol: "https", hostname: "cdn.jsdelivr.net", pathname: "/**" },
{ protocol: "https", hostname: "eu.cdn.beatsaver.com", pathname: "/**" },
{ protocol: "https", hostname: "na.cdn.beatsaver.com", pathname: "/**" },
{
protocol: "https",
hostname: "avatars.akamai.steamstatic.com",
pathname: "/**",
},
];
// Define optimized package imports
const optimizePackageImports = [
"react",
"react-dom",
"next-themes",
"react-tostify",
"websocket",
"cslx",
"chart.js",
"react-chartjs-2",
"country-list",
"@sentry/nextjs",
];
/** @type {import('next').NextConfig} */
const nextConfig = {
generateEtags: true,
reactStrictMode: true,
swcMinify: true,
compress: true,
poweredByHeader: false,
experimental: {
webpackBuildWorker: true,
optimizePackageImports,
},
env: {
NEXT_PUBLIC_BUILD_ID:
process.env.GIT_REV || nextBuildId.sync({ dir: __dirname }),
NEXT_PUBLIC_BUILD_TIME: new Date().toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
hour: "numeric",
minute: "numeric",
}),
},
images: {
remotePatterns: [
{
protocol: "https",
hostname: "cdn.fascinated.cc",
port: "",
pathname: "/**",
},
{
protocol: "https",
hostname: "cdn.scoresaber.com",
port: "",
pathname: "/**",
},
{
protocol: "https",
hostname: "cdn.jsdelivr.net",
port: "",
pathname: "/**",
},
],
},
images: { remotePatterns },
};
module.exports = withBundleAnalyzer(nextConfig);
// // Injected content via Sentry wizard below
const { withSentryConfig } = require("@sentry/nextjs");
module.exports = withSentryConfig(
module.exports,
{
// For all available options, see:
// https://github.com/getsentry/sentry-webpack-plugin#options
// Suppresses source map uploading logs during build
silent: true,
org: "sentry",
project: "scoresaber-reloaded",
url: "https://sentry.fascinated.cc/",
},
{
// For all available options, see:
// https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
// Upload a larger set of source maps for prettier stack traces (increases build time)
widenClientFileUpload: false,
// Transpiles SDK to be compatible with IE11 (increases bundle size)
transpileClientSDK: false,
// Routes browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers (increases server load)
tunnelRoute: "/monitoring",
// Hides source maps from generated client bundles
hideSourceMaps: true,
// Automatically tree-shake Sentry logger statements to reduce bundle size
disableLogger: true,
},
);

6765
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -2,55 +2,64 @@
"name": "scoresaber-reloadedv2",
"version": "0.1.0",
"private": true,
"sideEffects": false,
"scripts": {
"dev": "next dev",
"build": "next build",
"generate-sitemap": "next-sitemap",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@boiseitguru/cookie-cutter": "^0.2.1",
"@heroicons/react": "^2.0.18",
"@boiseitguru/cookie-cutter": "^0.2.3",
"@heroicons/react": "^2.1.1",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-radio-group": "^1.1.3",
"@radix-ui/react-separator": "^1.0.3",
"@radix-ui/react-slider": "^1.1.2",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tooltip": "^1.0.7",
"@sentry/nextjs": "^7.74.1",
"bluebird": "^3.7.2",
"chart.js": "^4.4.0",
"chart.js": "^4.4.1",
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"date-fns": "^2.30.0",
"clsx": "^2.1.0",
"country-list": "^2.3.0",
"critters": "^0.0.20",
"date-fns": "^3.0.0",
"encoding": "^0.1.13",
"idb-keyval": "^6.2.1",
"lucide-react": "^0.290.0",
"next": "14.0.0",
"infisical-node": "^1.5.1",
"lucide-react": "^0.320.0",
"next": "^14.1.0",
"next-build-id": "^3.0.0",
"next-sitemap": "^4.2.3",
"next-themes": "^0.2.1",
"node-fetch-cache": "^3.1.3",
"react": "^18",
"react": "^18.2.0",
"react-chartjs-2": "^5.2.0",
"react-dom": "^18",
"react-toastify": "^9.1.3",
"sharp": "^0.32.6",
"tailwind-merge": "^2.0.0",
"react-dom": "^18.2.0",
"react-toastify": "^10.0.4",
"redis": "^4.6.12",
"sharp": "^0.33.0",
"tailwind-merge": "^2.2.1",
"tailwindcss-animate": "^1.0.7",
"zustand": "^4.4.3"
"websocket": "^1.0.34",
"zustand": "^4.5.0"
},
"devDependencies": {
"@next/bundle-analyzer": "^14.0.0",
"@types/node": "^20",
"@types/node-fetch-cache": "^3.0.3",
"@types/react": "^18",
"@types/react-dom": "^18",
"autoprefixer": "^10.4.16",
"@next/bundle-analyzer": "^14.1.0",
"@types/node": "^20.11.14",
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
"@types/websocket": "^1.0.10",
"autoprefixer": "^10.4.17",
"cross-env": "^7.0.3",
"eslint": "^8",
"eslint-config-next": "14.0.0",
"postcss": "^8.4.31",
"prettier": "^3.0.3",
"prettier-plugin-tailwindcss": "^0.5.6",
"tailwindcss": "^3.3.3",
"typescript": "^5"
"eslint": "^8.56.0",
"eslint-config-next": "14.1.0",
"postcss": "^8.4.33",
"prettier": "^3.2.4",
"prettier-plugin-tailwindcss": "^0.5.11",
"tailwindcss": "^3.4.1",
"typescript": "^5.3.3"
}
}

4493
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

4
public/BingSiteAuth.xml Normal file
View File

@ -0,0 +1,4 @@
<?xml version="1.0"?>
<users>
<user>8100264FFBFADD8CD0134169A492D34B</user>
</users>

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -1,2 +1,9 @@
# *
User-agent: *
Allow: /
Allow: /
# Host
Host: https://ssr.fascinated.cc
# Sitemaps
Sitemap: https://ssr.fascinated.cc/sitemap.xml

View File

@ -1,15 +0,0 @@
// This file configures the initialization of Sentry on the client.
// The config you add here will be used whenever a users loads a page in their browser.
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from "@sentry/nextjs";
Sentry.init({
dsn: "https://1922e9d5b729f1568ad1e9537a118056@sentry.fascinated.cc/2",
// Adjust this value in production, or use tracesSampler for greater control
tracesSampleRate: 1,
// Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: false,
});

View File

@ -1,16 +0,0 @@
// This file configures the initialization of Sentry for edge features (middleware, edge routes, and so on).
// The config you add here will be used whenever one of the edge features is loaded.
// Note that this config is unrelated to the Vercel Edge Runtime and is also required when running locally.
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from "@sentry/nextjs";
Sentry.init({
dsn: "https://1922e9d5b729f1568ad1e9537a118056@sentry.fascinated.cc/2",
// Adjust this value in production, or use tracesSampler for greater control
tracesSampleRate: 1,
// Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: false,
});

View File

@ -1,15 +0,0 @@
// This file configures the initialization of Sentry on the server.
// The config you add here will be used whenever the server handles a request.
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from "@sentry/nextjs";
Sentry.init({
dsn: "https://1922e9d5b729f1568ad1e9537a118056@sentry.fascinated.cc/2",
// Adjust this value in production, or use tracesSampler for greater control
tracesSampleRate: 1,
// Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: false,
});

View File

@ -1,84 +0,0 @@
import AnalyticsChart from "@/components/AnalyticsChart";
import Card from "@/components/Card";
import Container from "@/components/Container";
import { ScoresaberMetricsHistory } from "@/schemas/fascinated/scoresaberMetricsHistory";
import { ssrSettings } from "@/ssrSettings";
import { formatNumber } from "@/utils/numberUtils";
import { isProduction } from "@/utils/utils";
import { Metadata } from "next";
import Link from "next/link";
async function getData() {
const response = await fetch(
"https://bs-tracker.fascinated.cc/analytics?time=30d",
{
next: {
revalidate: isProduction() ? 600 : 0, // 10 minutes (0 seconds in dev)
},
},
);
const json = await response.json();
return {
data: json as ScoresaberMetricsHistory,
};
}
export async function generateMetadata(): Promise<Metadata> {
const { data } = await getData();
const description =
"View Scoresaber metrics and statistics over the last 30 days.";
const lastActivePlayers =
data.activePlayersHistory[data.activePlayersHistory.length - 1].value;
const lastScoreCount =
data.scoreCountHistory[data.scoreCountHistory.length - 1].value;
return {
title: `Analytics`,
description: description,
openGraph: {
siteName: ssrSettings.siteName,
title: `Analytics`,
description:
description +
`
Players currently online: ${formatNumber(lastActivePlayers)}
Scores set Today: ${formatNumber(lastScoreCount)}`,
},
};
}
export default async function Analytics() {
const { data } = await getData();
return (
<main>
<Container>
<Card className="flex flex-col items-center justify-center">
<h1 className="text-center text-3xl font-bold">Analytics</h1>
<p className="text-center">
Scoresaber metrics and statistics over the last 30 days.
</p>
<p className="text-gray-300">
Want more in-depth data? Click{" "}
<span className="text-pp-blue">
<Link
href="https://grafana.fascinated.cc/d/b3c6c28d-39e9-4fa9-8e2b-b0ddb10f875e/beatsaber-metrics"
target="_blank"
rel="noopener noreferrer"
>
here
</Link>
</span>
</p>
<div className="mt-3 h-[400px] w-full">
<AnalyticsChart historyData={data} />
</div>
</Card>
</Container>
</main>
);
}

View File

@ -0,0 +1,100 @@
import { Redis } from "@/lib/db/redis";
import { BeatsaverMap } from "@/schemas/beatsaver/BeatsaverMap";
import { BeatsaverAPI } from "@/utils/beatsaver/api";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const mapHashes = searchParams.get("hashes");
if (!mapHashes) {
return new Response("mapHashes parameter is required", { status: 400 });
}
let toFetch: any[] = [];
if (mapHashes.includes(",")) {
const parts = mapHashes.substring(0, mapHashes.length - 1).split(",");
toFetch.push(...parts);
} else {
toFetch.push(mapHashes);
}
// Convert all hashes to uppercase
for (const hash of toFetch) {
toFetch[toFetch.indexOf(hash)] = hash.toUpperCase();
}
// Remove duplicates
toFetch = toFetch.filter((hash, index) => toFetch.indexOf(hash) === index);
const idOnly = searchParams.get("idonly") === "true";
let totalInCache = 0;
const maps: Record<string, BeatsaverMap | { id: string }> = {};
const fetchMapFromCache = async (mapHash: string) => {
const cachedMap = await (
await Redis.client
).get(`beatsaver:map:${mapHash}`);
return cachedMap;
};
const fetchAndCacheMap = async (mapHash: string) => {
const beatSaverMap = await BeatsaverAPI.fetchMapByHash(mapHash);
if (beatSaverMap) {
addMap(mapHash, beatSaverMap);
await cacheMap(mapHash, beatSaverMap);
}
};
const cacheMap = async (mapHash: string, map: BeatsaverMap) => {
await (
await Redis.client
).set(
`beatsaver:map:${mapHash}`,
JSON.stringify(idOnly ? { id: map.id } : map),
"EX",
60 * 60 * 24 * 7,
);
};
const addMap = (mapHash: string, map: any) => {
maps[mapHash] = idOnly ? { id: map.id } : map;
};
for (const mapHash of toFetch) {
const map = await fetchMapFromCache(mapHash);
if (map !== null) {
const json = JSON.parse(map);
addMap(mapHash, json);
totalInCache++;
}
}
if (totalInCache === 0 && toFetch.length > 1) {
const beatSaverMaps = await BeatsaverAPI.fetchMapsByHash(...toFetch);
if (beatSaverMaps) {
for (const mapHash of toFetch) {
const beatSaverMap = beatSaverMaps[mapHash.toLowerCase()];
if (beatSaverMap) {
await cacheMap(mapHash, beatSaverMap);
addMap(mapHash, beatSaverMap);
}
}
}
} else {
for (const mapHash of toFetch) {
if (!maps[mapHash]) {
await fetchAndCacheMap(mapHash);
}
}
}
return new Response(
JSON.stringify({
maps,
totalInCache,
}),
{
headers: { "content-type": "application/json;charset=UTF-8" },
},
);
}

View File

@ -21,7 +21,7 @@ export default async function Analytics() {
return (
<main>
<Container>
<Card>
<Card outerClassName="mt-2">
<h1 className="mb-1 text-3xl font-bold">Credits</h1>
<p className="mb-8 text-gray-300">
This website is made possible because of the following:

View File

@ -5,64 +5,47 @@
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 224 71.4% 4.1%;
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 224 71.4% 4.1%;
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 224 71.4% 4.1%;
--primary: 220.9 39.3% 11%;
--primary-foreground: 210 20% 98%;
--secondary: 220 14.3% 95.9%;
--secondary-foreground: 220.9 39.3% 11%;
--muted: 220 14.3% 95.9%;
--muted-foreground: 220 8.9% 46.1%;
--accent: 220 14.3% 95.9%;
--accent-foreground: 220.9 39.3% 11%;
--popover-foreground: 240 10% 3.9%;
--primary: 262.1 83.3% 57.8%;
--primary-foreground: 355.7 100% 97.3%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 20% 98%;
--border: 220 13% 91%;
--input: 220 13% 91%;
--ring: 224 71.4% 4.1%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 262.1 83.3% 57.8%;
--radius: 0.5rem;
}
.dark {
--background: 224 71.4% 4.1%;
--foreground: 210 20% 98%;
--card: 224 71.4% 4.1%;
--card-foreground: 210 20% 98%;
--popover: 224 71.4% 4.1%;
--popover-foreground: 210 20% 98%;
--primary: 210 20% 98%;
--primary-foreground: 220.9 39.3% 11%;
--secondary: 215 27.9% 16.9%;
--secondary-foreground: 210 20% 98%;
--muted: 215 27.9% 16.9%;
--muted-foreground: 217.9 10.6% 64.9%;
--accent: 215 27.9% 16.9%;
--accent-foreground: 210 20% 98%;
--background: 20 14.3% 4.1%;
--foreground: 0 0% 95%;
--card: 24 9.8% 10%;
--card-foreground: 0 0% 95%;
--popover: 0 0% 9%;
--popover-foreground: 0 0% 95%;
--primary: 262.1 83.3% 57.8%;
--primary-foreground: 144.9 80.4% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 15%;
--muted-foreground: 240 5% 64.9%;
--accent: 12 6.5% 15.1%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 20% 98%;
--border: 215 27.9% 16.9%;
--input: 215 27.9% 16.9%;
--ring: 216 12.2% 83.9%;
--destructive-foreground: 0 85.7% 97.3%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 262.1 83.3% 57.8%;
}
}

View File

@ -1,31 +1,33 @@
import AppProvider from "@/components/AppProvider";
import { ssrSettings } from "@/ssrSettings";
import { ThemeProvider } from "@/components/ui/theme-provider";
import ssrSettings from "@/ssrSettings.json";
import clsx from "clsx";
import { Metadata } from "next";
import { Metadata, Viewport } from "next";
import { Inter } from "next/font/google";
import Image from "next/image";
import Script from "next/script";
import "react-toastify/dist/ReactToastify.css";
import "./globals.css";
const font = Inter({ subsets: ["latin-ext"], weight: "500" });
// TODO: use type when NextJS fixes the type:
// export const viewport: Viewport = {
export const viewport: any = {
export const viewport: Viewport = {
themeColor: "#3B82F6",
};
export const metadata: Metadata = {
metadataBase: new URL(ssrSettings.siteUrl),
title: {
template: ssrSettings.siteName + " - %s",
template: ssrSettings.siteNameShort + " - %s",
default: ssrSettings.siteName,
},
description: ssrSettings.description,
keywords:
"scoresaber, score saber, scoresaber stats, score saber stats, beatleader, beat leader, " +
"scoresaber reloaded, ssr, github, score aggregation, scoresaber api, score saber api, scoresaber api",
"scoresaber, score saber, scoresaber stats, score saber stats, beatleader, beat leader," +
"scoresaber reloaded, ssr, github, score aggregation, scoresaber api, score saber api, scoresaber api," +
"BeatSaber, Overlay, OBS, Twitch, YouTube, BeatSaber Overlay, Github, Beat Saber overlay, ScoreSaber, BeatLeader," +
"VR gaming, Twitch stream enhancement, Customizable overlay, Real-time scores, Rankings, Leaderboard information," +
"Stream enhancement, Professional overlay, Easy to use overlay builder.",
openGraph: {
title: ssrSettings.siteName,
description: ssrSettings.description,
@ -44,22 +46,20 @@ export default function RootLayout({
<html lang="en">
<Script
id="plausible"
defer
data-domain="ssr.fascinated.cc"
src="https://analytics.fascinated.cc/js/script.js"
defer
/>
<body className={clsx(font.className, "bg-black text-white")}>
<div className="fixed left-0 top-0 z-0 h-full w-full blur-sm">
<Image
className="object-fill object-center"
alt="Background image"
src={"/assets/background.webp"}
fill
/>
</div>
<AppProvider>{children}</AppProvider>
<body className={clsx(font.className, "text-primary")}>
<ThemeProvider
storageKey="ssr-theme"
attribute="class"
defaultTheme="dark"
enableSystem
>
<AppProvider>{children}</AppProvider>
</ThemeProvider>
</body>
</html>
);

View File

@ -4,7 +4,10 @@ import Container from "@/components/Container";
export default async function NotFound() {
return (
<Container>
<Card className="flex h-full items-center justify-center">
<Card
outerClassName="mt-2"
className="flex h-full flex-col items-center justify-center"
>
<p className="text-xl font-bold text-red-500">404 Not Found</p>
<p className="text-lg text-gray-300">
The page you requested does not exist.

View File

@ -0,0 +1,142 @@
"use client";
import Container from "@/components/Container";
import { Input } from "@/components/input/Input";
import { RadioInput } from "@/components/input/RadioInput";
import { SwitchInput } from "@/components/input/SwitchInput";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardTitle } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { useOverlaySettingsStore } from "@/store/overlaySettingsStore";
import useStore from "@/utils/useStore";
import Link from "next/link";
import { toast } from "react-toastify";
/**
* Opens the overlay with the current settings
*
* @param settings the settings to pass to the overlay
*/
function openOverlay(settings: any) {
window.open(`/overlay?data=${JSON.stringify(settings)}`, "_blank");
}
export default function Analytics() {
const settingsStore = useStore(useOverlaySettingsStore, (store) => store);
if (!settingsStore) return null;
return (
<main>
<Container>
<Card className="mt-2">
<CardTitle className="p-3">
<h1>Overlay Builder</h1>
</CardTitle>
<CardContent>
<div className="mb-2">
<p>
Confused on how to use this? Check out the{" "}
<span className="transform-gpu text-pp-blue transition-all hover:opacity-80">
<Link href={"https://www.youtube.com/watch?v=IjctLf1nX8w"}>
tutorial
</Link>
</span>
.
</p>
<p>
The overlay requires{" "}
<span className="transform-gpu text-pp-blue transition-all hover:opacity-80">
<Link href={"https://github.com/denpadokei/HttpSiraStatus"}>
HttpSiraStatus
</Link>
</span>
</p>
</div>
<Input
id="ip-address"
label="IP Address"
defaultValue={settingsStore.ipAddress}
onChange={(e) => {
settingsStore.setIpAddress(e);
}}
/>
<Input
id="account-id"
label="Account ID"
defaultValue={settingsStore.accountId}
onChange={(e) => {
settingsStore.setAccountId(e);
}}
/>
<RadioInput
id="platform"
label="Platform"
defaultValue={settingsStore.platform}
items={[
{
id: "scoresaber",
value: "ScoreSaber",
},
{
id: "beatleader",
value: "BeatLeader",
},
]}
onChange={(value) => {
settingsStore.setPlatform(value);
}}
/>
<div className="mt-2">
<Label>Settings</Label>
<SwitchInput
id="show-player-stats"
label="Show Player Stats"
defaultChecked={settingsStore.settings.showPlayerStats}
onChange={(value) => {
settingsStore.setShowPlayerStats(value);
}}
/>
<SwitchInput
id="show-song-info"
label="Show Song Info"
defaultChecked={settingsStore.settings.showSongInfo}
onChange={(value) => {
settingsStore.setShowSongInfo(value);
}}
/>
<SwitchInput
id="show-score-stats"
label="Show Song Stats"
defaultChecked={settingsStore.settings.showScoreStats}
onChange={(value) => {
settingsStore.setShowScoreStats(value);
}}
/>
</div>
<Button
className="mt-3"
onClick={() => {
if (!settingsStore.ipAddress) {
toast.error("No IP Address provided");
return;
}
if (!settingsStore.accountId) {
toast.error("No Account ID provided");
return;
}
openOverlay(settingsStore);
}}
>
Open Overlay
</Button>
</CardContent>
</Card>
</Container>
</main>
);
}

138
src/app/overlay/page.tsx Normal file
View File

@ -0,0 +1,138 @@
"use client";
import Spinner from "@/components/Spinner";
import PlayerStats from "@/components/overlay/PlayerStats";
import ScoreStats from "@/components/overlay/ScoreStats";
import SongInfo from "@/components/overlay/SongInfo";
import { HttpSiraStatus } from "@/lib/overlay/httpSiraStatus";
import { OverlayPlayer } from "@/lib/overlay/type/overlayPlayer";
import { BeatLeaderAPI } from "@/utils/beatleader/api";
import { ScoreSaberAPI } from "@/utils/scoresaber/api";
import { Component } from "react";
const UPDATE_INTERVAL = 1000 * 60 * 5; // 5 minutes
interface OverlayProps {}
interface OverlayState {
mounted: boolean;
player: OverlayPlayer | undefined;
config: any | undefined;
}
export default class Overlay extends Component<OverlayProps, OverlayState> {
constructor(props: OverlayProps) {
super(props);
this.state = {
mounted: false,
player: undefined,
config: undefined,
};
}
updatePlayer = async (
playerId: string,
leaderboard: "scoresaber" | "beatleader" = "scoresaber",
) => {
console.log(`Updating player stats for ${playerId}`);
if (leaderboard == "scoresaber") {
const player = await ScoreSaberAPI.fetchPlayerData(playerId);
if (!player) {
return;
}
this.setState({
player: {
id: player.id,
profilePicture: player.profilePicture,
country: player.country,
pp: player.pp,
rank: player.rank,
countryRank: player.countryRank,
},
});
}
if (leaderboard == "beatleader") {
const player = await BeatLeaderAPI.fetchPlayerData(playerId);
if (!player) {
return;
}
this.setState({
player: {
id: player.id,
profilePicture: player.avatar,
country: player.country,
pp: player.pp,
rank: player.rank,
countryRank: player.countryRank,
},
});
}
};
componentDidMount() {
if (this.state.mounted) {
return;
}
this.setState({ mounted: true });
const url = new URL(window.location.href);
const searchParams = url.searchParams;
const data = searchParams.get("data");
if (!data) {
return;
}
const config = JSON.parse(data);
this.setState({ config: config });
const settings = config.settings;
if (settings.showPlayerStats) {
this.updatePlayer(config.accountId, config.platform);
setInterval(() => {
this.updatePlayer(config.accountId, config.platform);
}, UPDATE_INTERVAL);
}
if (settings.showScoreStats || settings.showSongInfo) {
HttpSiraStatus.connectWebSocket();
}
}
render() {
const { player, config, mounted } = this.state;
// Redirect to builder if no config is set
if (mounted && !config) {
window.location.href = "/overlay/builder";
return null;
}
// Show loading screen if player stats are enabled and not loaded yet
if (!mounted || (!player && config.settings.showPlayerStats)) {
return (
<main className="flex items-center !bg-transparent p-3">
<Spinner />
<p className="text-xl">Loading player data</p>
</main>
);
}
// The overlay
return (
<main>
<div>
{config.settings.showPlayerStats && player && (
<PlayerStats player={player} config={config} />
)}
{config.settings.showScoreStats && <ScoreStats />}
</div>
{config.settings.showSongInfo && (
<div className="absolute bottom-0 left-0">
<SongInfo />
</div>
)}
</main>
);
}
}

View File

@ -1,9 +1,23 @@
import PlayerPage from "@/components/player/PlayerPage";
import { ssrSettings } from "@/ssrSettings";
import Card from "@/components/Card";
import Container from "@/components/Container";
import PlayerChart from "@/components/player/PlayerChart";
import PlayerInfo from "@/components/player/PlayerInfo";
import Scores from "@/components/player/Scores";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/Tooltip";
import ssrSettings from "@/ssrSettings.json";
import { SortTypes } from "@/types/SortTypes";
import { formatNumber } from "@/utils/numberUtils";
import { ScoreSaberAPI } from "@/utils/scoresaber/api";
import { normalizedRegionName } from "@/utils/utils";
import clsx from "clsx";
import { Metadata } from "next";
import Image from "next/image";
const DEFAULT_SORT_TYPE = SortTypes.top;
type Props = {
params: { id: string; sort: string; page: string };
@ -47,6 +61,85 @@ export async function generateMetadata({
};
}
export default function Player({ params: { id, sort, page } }: Props) {
return <PlayerPage id={id} sort={sort} page={page} />;
/**
* Gets the player's data on the server side.
*
* @param id the player's id
* @returns the player's data
*/
async function getData(id: string, page: number, sort: string) {
const playerData = await ScoreSaberAPI.fetchPlayerData(id);
const playerScores = await ScoreSaberAPI.fetchScores(id, page, sort, 10);
return {
playerData: playerData,
playerScores: playerScores,
};
}
export default async function Player({ params: { id, sort, page } }: Props) {
const { playerData: player, playerScores } = await getData(
id,
Number(page),
sort,
);
if (!player) {
return (
<main>
<Container>
<Card outerClassName="mt-2">
<h1 className="text-2xl font-bold">Player not found</h1>
</Card>
</Container>
</main>
);
}
const sortType = SortTypes[sort] || DEFAULT_SORT_TYPE;
const badges = player.badges;
return (
<main>
<Container>
<PlayerInfo playerData={player} />
{/* Chart */}
<Card outerClassName="mt-2 min-h-[320px]">
{/* Badges */}
<div
className={clsx(
"mb-2 mt-2 flex flex-wrap items-center justify-center gap-2",
badges.length > 0 ? "block" : "hidden",
)}
>
{badges.map((badge) => {
return (
<Tooltip key={badge.image}>
<TooltipTrigger>
<Image
src={badge.image}
alt={badge.description}
width={80}
height={30}
/>
</TooltipTrigger>
<TooltipContent>
<p>{badge.description}</p>
</TooltipContent>
</Tooltip>
);
})}
</div>
<div className="h-[320px] w-full">
<PlayerChart scoresaber={player} />
</div>
</Card>
<Scores
initalScores={playerScores?.scores}
initalPage={Number(page)}
initalSortType={sortType}
initalTotalPages={playerScores?.pageInfo.totalPages}
playerData={player}
/>
</Container>
</main>
);
}

View File

@ -25,7 +25,7 @@ export default async function Analytics() {
return (
<main>
<Container>
<Card>
<Card outerClassName="mt-2">
<h1 className="mb-1 text-3xl font-bold">Privacy</h1>
<p className="mb-8 text-gray-300">
This site does not collect personal data. All of the data stored is

View File

@ -1,4 +1,8 @@
import Card from "@/components/Card";
import Container from "@/components/Container";
import Error from "@/components/Error";
import GlobalRanking from "@/components/GlobalRanking";
import { ScoreSaberAPI } from "@/utils/scoresaber/api";
import { Metadata } from "next";
export const metadata: Metadata = {
@ -9,6 +13,43 @@ type Props = {
params: { page: string; country: string };
};
export default function RankingGlobal({ params: { page, country } }: Props) {
return <GlobalRanking page={Number(page)} country={country} />;
async function getData(page: number, country: string) {
const response = await ScoreSaberAPI.fetchTopPlayers(page, country);
return {
data: response,
};
}
export default async function RankingGlobal({
params: { page, country },
}: Props) {
const { data } = await getData(Number(page), country);
if (!data) {
return (
<main>
<Container>
<Card outerClassName="mt-2" className="mt-2">
<div className="p-3 text-center">
<div role="status">
<div className="flex flex-col items-center justify-center gap-2">
<Error errorMessage="Unable to find this page or country" />
</div>
</div>
</div>
</Card>
</Container>
</main>
);
}
return (
<GlobalRanking
pageInfo={{
page: Number(page),
totalPages: data.pageInfo.totalPages,
}}
players={data.players}
country={country}
/>
);
}

View File

@ -1,4 +1,8 @@
import Card from "@/components/Card";
import Container from "@/components/Container";
import Error from "@/components/Error";
import GlobalRanking from "@/components/GlobalRanking";
import { ScoreSaberAPI } from "@/utils/scoresaber/api";
import { Metadata } from "next";
export const metadata: Metadata = {
@ -9,6 +13,40 @@ type Props = {
params: { page: string };
};
export default function RankingGlobal({ params: { page } }: Props) {
return <GlobalRanking page={Number(page)} />;
async function getData(page: number) {
const response = await ScoreSaberAPI.fetchTopPlayers(page);
return {
data: response,
};
}
export default async function RankingGlobal({ params: { page } }: Props) {
const { data } = await getData(Number(page));
if (!data) {
return (
<main>
<Container>
<Card outerClassName="mt-2" className="mt-2">
<div className="p-3 text-center">
<div role="status">
<div className="flex flex-col items-center justify-center gap-2">
<Error errorMessage="Unable to find this page" />
</div>
</div>
</div>
</Card>
</Container>
</main>
);
}
return (
<GlobalRanking
pageInfo={{
page: Number(page),
totalPages: data.pageInfo.totalPages,
}}
players={data.players}
/>
);
}

View File

@ -13,11 +13,14 @@ export default function Home() {
return (
<main>
<Container>
<Card className="flex flex-col items-center justify-center">
<Card
outerClassName="mt-2"
className="flex flex-col items-center justify-center"
>
<UnknownAvatar />
<p className="text-xl">Stranger</p>
<p className="text mt-2">Find a player profile</p>
<h1 className="text-xl">Search Player</h1>
<p className="text mt-2 text-gray-300">Find yourself or a friend</p>
<SearchPlayer />
</Card>

View File

@ -43,11 +43,19 @@ export const options: any = {
maxTicksLimit: 8,
stepSize: 1,
},
grid: {
// gray grid lines
color: "#252525",
},
},
x: {
ticks: {
autoSkip: true,
},
grid: {
// gray grid lines
color: "#252525",
},
},
},
elements: {

View File

@ -3,8 +3,9 @@
import { useScoresaberScoresStore } from "@/store/scoresaberScoresStore";
import { useSettingsStore } from "@/store/settingsStore";
import React from "react";
import { ToastContainer } from "react-toastify";
import { TooltipProvider } from "./ui/Tooltip";
import { ThemeProvider } from "./ui/theme-provider";
const UPDATE_INTERVAL = 1000 * 60 * 5; // 5 minutes
export default class AppProvider extends React.Component {
@ -50,9 +51,15 @@ export default class AppProvider extends React.Component {
const props: any = this.props;
return (
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem>
<TooltipProvider>{props.children}</TooltipProvider>
</ThemeProvider>
<TooltipProvider>
<ToastContainer
className="z-50"
position="top-right"
theme="dark"
pauseOnFocusLoss={false}
/>
{props.children}
</TooltipProvider>
);
}
}

View File

@ -3,38 +3,38 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/Tooltip";
interface ButtonProps {
text?: JSX.Element | string;
url?: string;
icon?: JSX.Element;
color?: string;
tooltip?: React.ReactNode;
className?: string;
ariaLabel?: string;
onClick?: () => void;
}
export default function Button({
text,
url,
icon,
color,
tooltip,
className,
ariaLabel = "Default button label",
onClick,
}: ButtonProps) {
if (!color) color = "bg-blue-500";
const base = (
<a href={url} onClick={onClick}>
<p
className={clsx(
"font-md flex w-fit transform-gpu flex-row items-center gap-1 rounded-md p-1 transition-all hover:opacity-80",
className,
color,
)}
>
{icon}
{text}
</p>
</a>
<button
className={clsx(
"flex items-center justify-center gap-2 rounded-md p-1",
color,
className,
)}
onClick={onClick}
aria-label={ariaLabel}
>
{icon}
{text}
</button>
);
if (tooltip) {

View File

@ -3,13 +3,20 @@ import clsx from "clsx";
type CardProps = {
className?: string;
outerClassName?: string;
children: React.ReactNode;
};
export default function Card({ className, children }: CardProps) {
export default function Card({
className,
outerClassName,
children,
}: CardProps) {
return (
<CardBase className="mt-2">
<CardContent className={clsx(className, "mt-2")}>{children}</CardContent>
<CardBase className={outerClassName}>
<CardContent className={clsx(className, "p-3 pb-4 pt-2")}>
{children}
</CardContent>
</CardBase>
);
}

View File

@ -1,17 +1,19 @@
import { ToastContainer } from "react-toastify";
import Image from "next/image";
import Footer from "./Footer";
import Navbar from "./Navbar";
import Navbar from "./navbar/Navbar";
export default function Container({ children }: { children: React.ReactNode }) {
return (
<>
<ToastContainer
className="z-50"
position="top-right"
theme="dark"
pauseOnFocusLoss={false}
/>
<div className="m-auto flex h-screen min-h-full flex-col items-center opacity-90 md:max-w-[1200px]">
<div className="fixed left-0 top-0 z-0 h-full w-full blur-sm">
<Image
className="object-fill object-center"
alt="Background image"
src={"/assets/background.webp"}
fill
/>
</div>
<div className="z-[9999] m-auto flex h-screen min-h-full flex-col items-center opacity-90 md:max-w-[1200px]">
<Navbar />
<div className="w-full flex-1">{children}</div>

View File

@ -1,5 +1,7 @@
import { ssrSettings } from "@/ssrSettings";
import ssrSettings from "@/ssrSettings.json";
import { isProduction } from "@/utils/utils";
import Link from "next/link";
import Card from "./Card";
const buttons = [
{
@ -13,42 +15,54 @@ const buttons = [
];
const buildId = process.env.NEXT_PUBLIC_BUILD_ID
? process.env.NEXT_PUBLIC_BUILD_ID.slice(0, 7) +
(isProduction() ? "" : "-dev")
? isProduction()
? process.env.NEXT_PUBLIC_BUILD_ID.slice(0, 7)
: "dev"
: "";
const buildTime = process.env.NEXT_PUBLIC_BUILD_TIME;
const gitUrl = isProduction()
? `https://git.fascinated.cc/Fascinated/scoresaber-reloaded-v2/commit/${buildId}`
: "https://s.fascinated.cc/s/ssr-gitea";
export default function Footer() {
return (
<footer>
<div className="bg-background m-3 flex flex-col items-center justify-center gap-1 rounded-md p-3">
<div className="flex flex-row gap-3">
<footer className="p-3">
<Card className="mb-2 mt-2 flex flex-col items-center justify-center gap-1 !pb-1 !pt-0">
<div className="flex flex-col items-center gap-1 md:flex-row md:items-start md:gap-3">
<a
className="transform-gpu transition-all hover:text-blue-500"
href="https://git.fascinated.cc/Fascinated/scoresaber-reloaded-v2"
href="https://s.fascinated.cc/s/ssr-gitea"
>
{ssrSettings.siteName}
</a>
{buttons.map((button, index) => {
return (
<div
key={index}
className="flex flex-row items-center justify-center gap-3"
>
<div className="h-4 w-[1px] bg-neutral-100"></div>
<a
href={button.url}
className="transform-gpu transition-all hover:text-blue-500"
<div className="flex divide-x divide-solid divide-neutral-500">
{buttons.map((button, index) => {
return (
<div
key={index}
className="flex flex-row items-center justify-center gap-3 pl-2 pr-2"
>
{button.name}
</a>
</div>
);
})}
<a
href={button.url}
className="transform-gpu transition-all hover:text-blue-500"
>
{button.name}
</a>
</div>
);
})}
</div>
</div>
<div className="text-sm text-gray-400">Build ID: {buildId}</div>
</div>
<Link
className="transform-gpu text-sm text-gray-400 transition-all hover:opacity-80"
href={gitUrl}
target="_blank"
>
Build ID: {buildId} ({buildTime})
</Link>
</Card>
</footer>
);
}

View File

@ -1,194 +1,81 @@
"use client";
import { ScoresaberPlayer } from "@/schemas/scoresaber/player";
import { ScoreSaberAPI } from "@/utils/scoresaber/api";
import { normalizedRegionName } from "@/utils/utils";
import dynamic from "next/dynamic";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import Card from "./Card";
import Container from "./Container";
import CountyFlag from "./CountryFlag";
import Pagination from "./Pagination";
import Spinner from "./Spinner";
import PlayerRanking from "./player/PlayerRanking";
import PlayerRankingMobile from "./player/PlayerRankingMobile";
import { Separator } from "./ui/separator";
const Error = dynamic(() => import("@/components/Error"));
type PageInfo = {
loading: boolean;
page: number;
totalPages: number;
players: ScoresaberPlayer[];
};
type GlobalRankingProps = {
page: number;
players: ScoresaberPlayer[];
country?: string;
pageInfo: {
page: number;
totalPages: number;
};
};
export default function GlobalRanking({ page, country }: GlobalRankingProps) {
const router = useRouter();
const searchQuery = useSearchParams();
const isMobile = searchQuery.get("mobile") == "true";
const [error, setError] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const [pageInfo, setPageInfo] = useState<PageInfo>({
loading: true,
page: page,
totalPages: 1,
players: [],
});
const updatePage = useCallback(
(page: any) => {
const windowSize = document.documentElement.clientWidth;
if (windowSize < 768 && !isMobile) {
router.push(`/ranking/global/${page}?mobile=true`);
router.refresh();
return;
}
console.log("Switching page to", page);
ScoreSaberAPI.fetchTopPlayers(page, country).then((response) => {
if (!response) {
setError(true);
setErrorMessage("No players found");
setPageInfo({ ...pageInfo, loading: false });
return;
}
setPageInfo({
...pageInfo,
players: response.players,
totalPages: response.pageInfo.totalPages,
loading: false,
page: page,
});
window.history.pushState(
{},
"",
country
? `/ranking/country/${country}/${page}`
: `/ranking/global/${page}`,
);
});
},
[country, isMobile, pageInfo, router],
);
useEffect(() => {
if (!pageInfo.loading || error) return;
updatePage(pageInfo.page);
}, [error, country, updatePage, pageInfo.page, pageInfo.loading]);
if (pageInfo.loading || error) {
return (
<main>
<Container>
<Card className="mt-2">
<div className="p-3 text-center">
<div role="status">
<div className="flex flex-col items-center justify-center gap-2">
{error && <Error errorMessage={errorMessage} />}
{!error && <Spinner />}
</div>
</div>
</div>
</Card>
</Container>
</main>
);
}
const players = pageInfo.players;
export default function GlobalRanking({
players,
country,
pageInfo,
}: GlobalRankingProps) {
return (
<main>
<Container>
<Card className="mt-2">
{pageInfo.loading ? (
<div className="flex justify-center">
<Spinner />
<Card outerClassName="mt-2" className="mt-2">
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2 p-2">
{country && (
<CountyFlag countryCode={country} className="!h-8 !w-8" />
)}
<p>
You are viewing{" "}
{country
? "scores from " + normalizedRegionName(country.toUpperCase())
: "Global scores"}
</p>
</div>
) : (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2 p-2">
{country && (
<CountyFlag countryCode={country} className="!h-8 !w-8" />
)}
<p>
You are viewing{" "}
{country
? "scores from " + normalizedRegionName(country)
: "Global scores"}
</p>
</div>
<Separator />
<Separator />
{!isMobile && (
<table className="table w-full table-auto border-spacing-2 border-none text-left">
<thead>
<tr>
<th className="px-4 py-2">Rank</th>
<th className="px-4 py-2">Profile</th>
<th className="px-4 py-2">Performance Points</th>
<th className="px-4 py-2">Total Plays</th>
<th className="px-4 py-2">Total Ranked Plays</th>
<th className="px-4 py-2">Avg Ranked Accuracy</th>
</tr>
</thead>
<tbody className="border-none">
{players.map((player) => (
<tr
key={player.rank}
className="border-b border-gray-800"
>
<PlayerRanking
showCountryFlag={country ? false : true}
player={player}
/>
</tr>
))}
</tbody>
</table>
)}
<table className="table w-full table-auto border-spacing-2 border-none text-left">
<thead>
<tr>
<th className="px-4 py-2">Rank</th>
<th className="px-4 py-2">Profile</th>
<th className="px-4 py-2">Performance Points</th>
<th className="px-4 py-2">Total Plays</th>
<th className="px-4 py-2">Total Ranked Plays</th>
<th className="px-4 py-2">Avg Ranked Accuracy</th>
</tr>
</thead>
<tbody className="border-none">
{players.map((player) => (
<tr key={player.rank} className="border-b border-border">
<PlayerRanking
isCountry={country == undefined ? false : true}
player={player}
/>
</tr>
))}
</tbody>
</table>
{isMobile && (
<div className="flex flex-col gap-2">
{players.map((player) => (
<div
key={player.rank}
className="flex flex-col gap-2 rounded-md bg-gray-700 hover:bg-gray-600"
>
<Link href={`/player/${player.id}/top/1`}>
<PlayerRankingMobile player={player} />
</Link>
</div>
))}
</div>
)}
{/* Pagination */}
<div className="flex w-full flex-row justify-center">
<div className="pt-3">
<Pagination
currentPage={pageInfo.page}
totalPages={pageInfo.totalPages}
onPageChange={(page) => {
updatePage(page);
}}
/>
</div>
{/* Pagination */}
<div className="flex w-full flex-row justify-center">
<div className="pt-3">
<Pagination
currentPage={pageInfo.page}
totalPages={pageInfo.totalPages}
useHref
/>
</div>
</div>
)}
</div>
</Card>
</Container>
</main>

View File

@ -1,133 +0,0 @@
"use client";
import { useSettingsStore } from "@/store/settingsStore";
import useStore from "@/utils/useStore";
import {
CogIcon,
MagnifyingGlassIcon,
ServerIcon,
UserIcon,
} from "@heroicons/react/20/solid";
import { GlobeAltIcon } from "@heroicons/react/24/outline";
import Avatar from "./Avatar";
import Button from "./Button";
interface ButtonProps {
text: string;
icon?: JSX.Element;
href?: string;
ariaLabel: string;
children?: React.ReactNode;
}
function NavbarButton({ text, icon, href, ariaLabel, children }: ButtonProps) {
return (
<div className="group">
<a
aria-label={ariaLabel}
className="flex h-full w-fit transform-gpu items-center justify-center gap-1 rounded-md p-3 transition-all hover:cursor-pointer hover:bg-blue-500"
href={href}
>
<>
{icon}
<p className="hidden md:block">{text}</p>
</>
</a>
{children && (
<div className="absolute z-20 hidden divide-y rounded-md bg-gray-600 opacity-[0.98] shadow-sm group-hover:flex">
<div className="p-2">{children}</div>
</div>
)}
</div>
);
}
export default function Navbar() {
const settingsStore = useStore(useSettingsStore, (state) => state);
return (
<>
<div className="bg-background flex h-fit w-full rounded-md">
{settingsStore !== undefined && settingsStore.player && (
<NavbarButton
ariaLabel="Your profile"
text="You"
icon={
<Avatar
url={settingsStore.player.profilePicture}
label="Your avatar"
size={32}
/>
}
href={`/player/${settingsStore.player.id}/top/1`}
/>
)}
<NavbarButton
ariaLabel="View your friends"
text="Friends"
icon={<UserIcon height={20} width={20} />}
href="/search"
>
{settingsStore?.friends.length == 0 ? (
<p className="text-sm font-bold">No friends, add someone!</p>
) : (
settingsStore?.friends.map((friend) => {
return (
<Button
key={friend.id}
className="mt-2"
color="bg-gray-500"
text={friend.name}
url={`/player/${friend.id}/top/1`}
icon={
<Avatar
url={friend.profilePicture}
label={`${friend.name}'s avatar`}
size={20}
/>
}
/>
);
})
)}
<Button
className="mt-2"
text="Search"
url="/search"
icon={<MagnifyingGlassIcon height={20} width={20} />}
/>
</NavbarButton>
<NavbarButton
ariaLabel="View the global ranking"
text="Ranking"
icon={<GlobeAltIcon height={20} width={20} />}
href="/ranking/global/1"
/>
<NavbarButton
ariaLabel="View analytics for Scoresaber"
text="Analytics"
icon={<ServerIcon height={20} width={20} />}
href="/analytics"
/>
<div className="m-auto" />
<NavbarButton
ariaLabel="Search for a player"
text="Search"
icon={<MagnifyingGlassIcon height={20} width={20} />}
href="/search"
/>
<NavbarButton
ariaLabel="View your settings"
text="Settings"
icon={<CogIcon height={20} width={20} />}
href="/settings"
/>
</div>
</>
);
}

View File

@ -2,15 +2,17 @@ import {
ArrowUturnLeftIcon,
ArrowUturnRightIcon,
} from "@heroicons/react/20/solid";
import Link from "next/link";
type PaginationProps = {
currentPage: number;
totalPages: number;
onPageChange: (pageNumber: number) => void;
useHref?: boolean;
onPageChange?: (pageNumber: number) => void;
};
export default function Pagination(props: PaginationProps) {
const { currentPage, totalPages, onPageChange } = props;
const { currentPage, totalPages, useHref, onPageChange } = props;
// Calculate the range of page numbers to display
const rangeStart = Math.max(1, currentPage - 2);
@ -23,31 +25,53 @@ export default function Pagination(props: PaginationProps) {
}
return (
<div className="flex justify-center text-white">
<div className="flex justify-center text-primary">
<nav>
<ul className="flex items-center gap-2">
{currentPage > 1 && (
<li className="rounded-md bg-neutral-700 hover:opacity-80">
<button
className="px-3 py-1"
onClick={() => onPageChange(currentPage - 1)}
aria-label={`Page ${currentPage - 1} (previous page)`}
>
<ArrowUturnLeftIcon width={20} height={20} />
</button>
{useHref ? (
<Link href={`${currentPage - 1}`}>
<p
className="px-3 py-1"
aria-label={`Page ${currentPage - 1} (previous page)`}
>
<ArrowUturnLeftIcon width={20} height={20} />
</p>
</Link>
) : (
<button
className="px-3 py-1"
onClick={() => onPageChange && onPageChange(currentPage - 1)}
aria-label={`Page ${currentPage - 1} (previous page)`}
>
<ArrowUturnLeftIcon width={20} height={20} />
</button>
)}
</li>
)}
{currentPage !== 1 && currentPage - 2 > 1 && (
<>
<li>
<button
className="rounded-md bg-neutral-700 px-3 py-1 hover:opacity-80"
onClick={() => onPageChange(1)}
aria-label="Page 1 (first page)"
>
1
</button>
{useHref ? (
<Link href={`${1}`}>
<p
className="rounded-md bg-neutral-700 px-3 py-1 hover:opacity-80"
aria-label="Page 1 (first page)"
>
1
</p>
</Link>
) : (
<button
className="rounded-md bg-neutral-700 px-3 py-1 hover:opacity-80"
onClick={() => onPageChange && onPageChange(1)}
aria-label="Page 1 (first page)"
>
1
</button>
)}
</li>
<li>
<p>...</p>
@ -57,17 +81,32 @@ export default function Pagination(props: PaginationProps) {
{pageNumbers.map((pageNumber) => (
<li key={pageNumber}>
<button
className={`rounded-md px-3 py-1 ${
pageNumber === currentPage
? "bg-blue-500 text-white"
: "bg-neutral-700 hover:opacity-80"
}`}
onClick={() => onPageChange(pageNumber)}
aria-label={`Page ${pageNumber}`}
>
{pageNumber}
</button>
{useHref ? (
<Link href={`${pageNumber}`}>
<p
className={`rounded-md px-3 py-1 ${
pageNumber === currentPage
? "bg-blue-500 text-primary"
: "bg-neutral-700 hover:opacity-80"
}`}
aria-label={`Page ${pageNumber}`}
>
{pageNumber}
</p>
</Link>
) : (
<button
className={`rounded-md px-3 py-1 ${
pageNumber === currentPage
? "bg-blue-500 text-primary"
: "bg-neutral-700 hover:opacity-80"
}`}
onClick={() => onPageChange && onPageChange(pageNumber)}
aria-label={`Page ${pageNumber}`}
>
{pageNumber}
</button>
)}
</li>
))}
@ -78,26 +117,48 @@ export default function Pagination(props: PaginationProps) {
</li>
<li>
<button
className="rounded-md bg-neutral-700 px-3 py-1 hover:opacity-80"
onClick={() => onPageChange(totalPages)}
aria-label={`Page ${totalPages} (last page)`}
>
{totalPages}
</button>
{useHref ? (
<Link href={`${totalPages}`}>
<p
className="rounded-md bg-neutral-700 px-3 py-1 hover:opacity-80"
aria-label={`Page ${totalPages} (last page)`}
>
{totalPages}
</p>
</Link>
) : (
<button
className="rounded-md bg-neutral-700 px-3 py-1 hover:opacity-80"
onClick={() => onPageChange && onPageChange(totalPages)}
aria-label={`Page ${totalPages} (last page)`}
>
{totalPages}
</button>
)}
</li>
</>
)}
{currentPage < totalPages && (
<li className="rounded-md bg-neutral-700 hover:opacity-80">
<button
className="px-3 py-1"
onClick={() => onPageChange(currentPage + 1)}
aria-label={`Page ${currentPage + 1} (next page)`}
>
<ArrowUturnRightIcon width={20} height={20} />
</button>
{useHref ? (
<Link href={`${currentPage + 1}`}>
<p
className="px-3 py-1"
aria-label={`Page ${currentPage + 1} (next page)`}
>
<ArrowUturnRightIcon width={20} height={20} />
</p>
</Link>
) : (
<button
className="px-3 py-1"
onClick={() => onPageChange && onPageChange(currentPage + 1)}
aria-label={`Page ${currentPage + 1} (next page)`}
>
<ArrowUturnRightIcon width={20} height={20} />
</button>
)}
</li>
)}
</ul>

View File

@ -5,12 +5,15 @@ import { formatNumber } from "@/utils/numberUtils";
import { ScoreSaberAPI } from "@/utils/scoresaber/api";
import { MagnifyingGlassIcon } from "@heroicons/react/20/solid";
import clsx from "clsx";
import Link from "next/link";
import { useEffect, useState } from "react";
import Avatar from "./Avatar";
export default function SearchPlayer() {
const [search, setSearch] = useState("");
const [players, setPlayers] = useState([] as ScoresaberPlayer[]);
const [players, setPlayers] = useState(
undefined as ScoresaberPlayer[] | undefined,
);
useEffect(() => {
// Don't search if the query is too short
@ -28,14 +31,20 @@ export default function SearchPlayer() {
if (id == undefined) return;
const player = await ScoreSaberAPI.fetchPlayerData(id);
if (player == undefined) return;
if (player == undefined) {
setPlayers([]);
return;
}
setPlayers([player]);
}
// Search by name
const players = await ScoreSaberAPI.searchByName(search);
if (players == undefined) return;
if (players == undefined) {
setPlayers([]);
return;
}
setPlayers(players);
}
@ -44,7 +53,7 @@ export default function SearchPlayer() {
e.preventDefault();
// Take the user to the first account
if (players.length > 0) {
if (players && players.length > 0) {
window.location.href = `/player/${players[0].id}/top/1`;
}
}
@ -67,26 +76,28 @@ export default function SearchPlayer() {
<div
className={clsx(
"absolute z-20 mt-7 flex max-h-[200px] min-w-[14rem] flex-col divide-y overflow-y-auto rounded-md bg-gray-700 shadow-sm md:max-h-[300px]",
players.length > 0 ? "flex" : "hidden",
"absolute z-20 mt-7 flex max-h-[200px] min-w-[14rem] flex-col divide-y overflow-y-auto rounded-md bg-popover shadow-sm md:max-h-[300px]",
players ? "flex" : "hidden",
)}
>
{players.map((player: ScoresaberPlayer) => (
<a
key={player.id}
className="flex min-w-[14rem] items-center gap-2 rounded-md p-2 transition-all hover:bg-gray-600"
href={`/player/${player.id}/top/1`}
>
<Avatar label="Account" size={40} url={player.profilePicture} />
{players && players.length > 0
? players.map((player: ScoresaberPlayer) => (
<Link
key={player.id}
className="flex min-w-[14rem] items-center gap-2 p-2 transition-all hover:bg-background"
href={`/player/${player.id}/top/1`}
>
<Avatar label="Account" size={40} url={player.profilePicture} />
<div>
<p className="text-xs text-gray-400">
#{formatNumber(player.rank)}
</p>
<p className="text-sm">{player.name}</p>
</div>
</a>
))}
<div>
<p className="text-xs text-gray-400">
#{formatNumber(player.rank)}
</p>
<p className="text-sm">{player.name}</p>
</div>
</Link>
))
: search.length > 0 && <div className="p-2">No players found</div>}
</div>
</form>
);

View File

@ -16,14 +16,14 @@ export default function BeatSaverLogo({
version="1.1"
className={className}
>
<g fill="none" stroke="#000000" stroke-width="10">
<path d="M 100,7 189,47 100,87 12,47 Z" stroke-linejoin="round"></path>
<g fill="none" stroke="#000000" strokeWidth="10">
<path d="M 100,7 189,47 100,87 12,47 Z" strokeLinejoin="round"></path>
<path
d="M 189,47 189,155 100,196 12,155 12,47"
stroke-linejoin="round"
strokeLinejoin="round"
></path>
<path d="M 100,87 100,196" stroke-linejoin="round"></path>
<path d="M 26,77 85,106 53,130 Z" stroke-linejoin="round"></path>
<path d="M 100,87 100,196" strokeLinejoin="round"></path>
<path d="M 26,77 85,106 53,130 Z" strokeLinejoin="round"></path>
</g>
</svg>
);

View File

@ -0,0 +1,33 @@
type YouTubeLogoProps = {
size?: number;
className?: string;
};
export default function YouTubeLogo({
size = 32,
className,
}: YouTubeLogoProps) {
return (
<svg
height={size}
width={size}
version="1.1"
id="Layer_1"
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
viewBox="0 0 461.001 461.001"
xmlSpace="preserve"
className={className}
>
<g>
<path
fill="#F61C0D"
d="M365.257,67.393H95.744C42.866,67.393,0,110.259,0,163.137v134.728
c0,52.878,42.866,95.744,95.744,95.744h269.513c52.878,0,95.744-42.866,95.744-95.744V163.137
C461.001,110.259,418.135,67.393,365.257,67.393z M300.506,237.056l-126.06,60.123c-3.359,1.602-7.239-0.847-7.239-4.568V168.607
c0-3.774,3.982-6.22,7.348-4.514l126.06,63.881C304.363,229.873,304.298,235.248,300.506,237.056z"
/>
</g>
</svg>
);
}

View File

@ -0,0 +1,24 @@
import { Input as Inputtt } from "../ui/input";
import { Label } from "../ui/label";
type InputProps = {
label: string;
id: string;
defaultValue?: string;
onChange?: (value: string) => void;
};
export function Input({ label, id, defaultValue, onChange }: InputProps) {
return (
<>
<Label htmlFor={id}>{label}</Label>
<Inputtt
id={id}
defaultValue={defaultValue}
onChange={(e) => {
onChange && onChange(e.target.value);
}}
/>
</>
);
}

View File

@ -0,0 +1,45 @@
import { Label } from "../ui/label";
import { RadioGroup, RadioGroupItem } from "../ui/radio-group";
type RadioProps = {
id: string;
defaultValue: string;
label?: string;
items: {
id: string;
value: string;
label?: string;
}[];
onChange?: (value: string) => void;
};
export function RadioInput({
id,
defaultValue,
label,
items,
onChange,
}: RadioProps) {
return (
<div className="mt-2">
{id && label && <Label htmlFor={id}>{label}</Label>}
<RadioGroup
id={id}
defaultValue={defaultValue}
className="mt-2"
onValueChange={(value) => onChange && onChange(value)}
>
{items.map((item, index) => {
return (
<div key={index} className="flex items-center space-x-2">
<RadioGroupItem value={item.id} id={item.id}>
{item.value}
</RadioGroupItem>
<Label htmlFor={item.id}>{item.value}</Label>
</div>
);
})}
</RadioGroup>
</div>
);
}

View File

@ -0,0 +1,27 @@
import { Label } from "../ui/label";
import { Switch } from "../ui/switch";
type SwitchProps = {
id: string;
label: string;
defaultChecked?: boolean;
onChange?: (value: boolean) => void;
};
export function SwitchInput({
id,
label,
defaultChecked,
onChange,
}: SwitchProps) {
return (
<div className="mt-2 flex items-center space-x-2">
<Switch
id={id}
defaultChecked={defaultChecked}
onCheckedChange={(value) => onChange && onChange(value)}
/>
<Label htmlFor={id}>{label}</Label>
</div>
);
}

View File

@ -96,8 +96,8 @@ export default function Leaderboard({ id, page }: LeaderboardProps) {
return (
<Container>
<div className="flex flex-col gap-2 md:flex-row">
<Card className="mt-2 flex">
<div className="mt-2 flex flex-col gap-2 xl:flex-row">
<Card outerClassName="h-fit pt-3" className="flex">
<div className="flex min-w-[300px] flex-wrap justify-between gap-2 md:justify-start">
<div className="flex gap-2">
<Image
@ -109,17 +109,19 @@ export default function Leaderboard({ id, page }: LeaderboardProps) {
/>
<div className="flex flex-col">
<p className="text-xl font-bold">{songName}</p>
<p className="text-lg">{songSubName}</p>
<p>Mapped By: {levelAuthorName}</p>
{/* <p className="text-lg">{songSubName}</p> */}
<p className="text-gray-400">{levelAuthorName}</p>
</div>
</div>
<div className="flex flex-col gap-1">
<p>Status: {ranked ? "Ranked" : "Unranked"}</p>
<div className="flex">
<p>Stars:</p>
<StarIcon width={20} height={20} className="ml-1" />
<p>{stars}</p>
</div>
{ranked && (
<div className="flex">
<p>Stars:</p>
<StarIcon width={20} height={20} className="ml-1" />
<p className="text-pp-blue">{stars}</p>
</div>
)}
<p>
Plays: {formatNumber(plays)} ({dailyPlays} in the last day)
</p>
@ -127,7 +129,7 @@ export default function Leaderboard({ id, page }: LeaderboardProps) {
</div>
</Card>
<Card className="mt-2 h-fit">
<div className="mb-2 mt-2 flex justify-center gap-2">
<div className="mb-2 flex justify-center gap-2">
{difficulties.map((diff) => {
return (
<div
@ -145,7 +147,7 @@ export default function Leaderboard({ id, page }: LeaderboardProps) {
);
})}
</div>
<div className="grid grid-cols-1 divide-y divide-gray-500">
<div className="grid grid-cols-1 divide-y divide-border">
{leaderboardScores?.map((score, index) => {
return (
<div key={index}>

View File

@ -21,14 +21,14 @@ export default function LeaderboardScore({
const accuracy = ((score.baseScore / leaderboard.maxScore) * 100).toFixed(2);
return (
<div className="mb-1 mt-1 grid grid-cols-[0.6fr_3fr_1.3fr] first:pt-0 last:pb-0 md:grid-cols-[1.2fr_6fr_1.3fr]">
<div className="mb-1 mt-1 grid grid-cols-[1.28fr_3fr_1.3fr] first:pt-0 last:pb-0 md:grid-cols-[1.28fr_6fr_1.3fr]">
<div className="flex flex-col items-center justify-center">
<div className="flex w-fit flex-row items-center justify-center gap-1">
<p>#{formatNumber(score.rank)}</p>
</div>
<Tooltip>
<TooltipTrigger>
<p className="hidden text-sm text-gray-200 md:block">
<p className="block text-sm text-gray-200">
{formatTimeAgo(score.timeSet)}
</p>
</TooltipTrigger>
@ -57,7 +57,7 @@ export default function LeaderboardScore({
href={`/leaderboard/${leaderboard.id}/1`}
className="transform-gpu transition-all hover:opacity-70"
>
<div className="w-fit truncate text-blue-500">
<div className="w-fit truncate">
<p className="font-bold">{player.name}</p>
</div>
</Link>
@ -67,7 +67,7 @@ export default function LeaderboardScore({
{/* PP */}
{score.pp > 0 && (
<ScoreStatLabel
className="bg-blue-500 text-center"
className="bg-pp-blue text-center"
value={formatNumber(score.pp.toFixed(2)) + "pp"}
/>
)}

View File

@ -0,0 +1,66 @@
"use client";
import { useSettingsStore } from "@/store/settingsStore";
import useStore from "@/utils/useStore";
import { UserIcon } from "@heroicons/react/20/solid";
import Link from "next/link";
import Avatar from "../Avatar";
import { Button } from "../ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
import NavbarButton from "./NavbarButton";
export default function FriendsButton() {
const settingsStore = useStore(useSettingsStore, (state) => state);
return (
<Popover>
<PopoverTrigger>
<NavbarButton
ariaLabel="View your friends"
text="Friends"
icon={<UserIcon height={23} width={23} />}
/>
</PopoverTrigger>
<PopoverContent className="p-2">
{settingsStore?.friends.length == 0 ? (
<div className="flex flex-col gap-2">
<div>
<p className="text-md font-bold">No friends</p>
<p className="text-sm text-gray-400">
Add new friends by clicking below
</p>
</div>
<Link href={"/search"}>
<Button className="w-full" size={"sm"}>
Search
</Button>
</Link>
</div>
) : (
settingsStore?.friends.map((friend) => {
return (
<Link
key={friend.id}
href={`/player/${friend.id}/top/1`}
className="w-full"
>
<div className="flex transform-gpu gap-2 rounded-md p-2 text-left transition-all hover:bg-background">
<Avatar
url={friend.profilePicture}
label="Friend avatar"
size={48}
/>
<div>
<p className="text-sm text-gray-400">#{friend.rank}</p>
<p>{friend.name}</p>
</div>
</div>
</Link>
);
})
)}
</PopoverContent>
</Popover>
);
}

View File

@ -0,0 +1,46 @@
import { MagnifyingGlassIcon } from "@heroicons/react/20/solid";
import { GlobeAltIcon, TvIcon } from "@heroicons/react/24/outline";
import { Card } from "../ui/card";
import FriendsButton from "./FriendsButton";
import NavbarButton from "./NavbarButton";
import YouButton from "./YouButton";
export default function Navbar() {
return (
<>
<Card className="flex h-fit w-full rounded-md">
<YouButton />
<FriendsButton />
<NavbarButton
ariaLabel="View the global ranking"
text="Ranking"
icon={<GlobeAltIcon height={23} width={23} />}
href="/ranking/global/1"
/>
<NavbarButton
ariaLabel="View the overlay builder"
text="Overlay"
icon={<TvIcon height={23} width={23} />}
href="/overlay/builder"
/>
<div className="m-auto" />
<NavbarButton
ariaLabel="Search for a player"
text="Search"
icon={<MagnifyingGlassIcon height={23} width={23} />}
href="/search"
/>
{/* <NavbarButton
ariaLabel="View your settings"
text="Settings"
icon={<CogIcon height={23} width={23} />}
href="/settings"
/> */}
</Card>
</>
);
}

View File

@ -0,0 +1,26 @@
interface ButtonProps {
text: string;
icon?: JSX.Element;
href?: string;
ariaLabel: string;
}
export default function NavbarButton({
text,
icon,
href,
ariaLabel,
}: ButtonProps) {
return (
<a
aria-label={ariaLabel}
className="flex h-full w-fit transform-gpu items-center justify-center gap-1 rounded-md p-[8px] transition-all hover:cursor-pointer hover:bg-blue-500"
href={href}
>
<>
{icon}
<p className="hidden md:block">{text}</p>
</>
</a>
);
}

View File

@ -0,0 +1,29 @@
"use client";
import { useSettingsStore } from "@/store/settingsStore";
import { useStore } from "zustand";
import Avatar from "../Avatar";
import NavbarButton from "./NavbarButton";
export default function YouButton() {
const settingsStore = useStore(useSettingsStore, (state) => state);
if (!settingsStore || !settingsStore.player) {
return null;
}
return (
<NavbarButton
ariaLabel="Your profile"
text="You"
icon={
<Avatar
url={settingsStore.player.profilePicture}
label="Your avatar"
size={23}
/>
}
href={`/player/${settingsStore.player.id}/top/1`}
/>
);
}

View File

@ -0,0 +1,50 @@
import { OverlayPlayer } from "@/lib/overlay/type/overlayPlayer";
import { formatNumber } from "@/utils/numberUtils";
import { GlobeAltIcon } from "@heroicons/react/20/solid";
import Image from "next/image";
import CountyFlag from "../CountryFlag";
type PlayerStatsProps = {
player: OverlayPlayer;
config: any;
};
const leaderboardImages: Record<string, string> = {
scoresaber: "/assets/logos/scoresaber.png",
beatleader: "/assets/logos/beatleader.png",
};
export default function PlayerStats({ player, config }: PlayerStatsProps) {
return (
<div className="flex gap-2 p-2">
<Image
alt="Player profile picture"
className="rounded-lg"
src={player.profilePicture}
width={180}
height={180}
priority
/>
<div>
<div className="flex gap-1">
<Image
alt="Leaderboard logo"
src={leaderboardImages[config.platform]}
width={36}
height={36}
priority
/>
<p className="text-3xl">{formatNumber(player.pp, 2)}pp</p>
</div>
<div className="flex items-center gap-2">
<GlobeAltIcon width={25} height={25} />
<p className="text-3xl">#{formatNumber(player.rank)}</p>
</div>
<div className="flex items-center gap-2">
<CountyFlag className="w-[25px]" countryCode={player.country} />
<p className="text-3xl">#{formatNumber(player.countryRank)}</p>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,28 @@
import { useOverlayDataStore } from "@/store/overlayDataStore";
import { formatNumber } from "@/utils/numberUtils";
import { accuracyToColor } from "@/utils/songUtils";
import useStore from "@/utils/useStore";
export default function ScoreStats() {
const dataStore = useStore(useOverlayDataStore, (store) => store);
if (!dataStore) return null;
const { scoreStats } = dataStore;
if (!scoreStats) return null;
return (
<div className="flex flex-col pl-2">
<p className="text-2xl font-bold">{formatNumber(scoreStats.score)}</p>
<p className="text-2xl">{formatNumber(scoreStats.combo)}x</p>
<p className="text-2xl">
<span
style={{
color: accuracyToColor(scoreStats.accuracy),
}}
>
{scoreStats.accuracy == 100 ? "SS" : scoreStats.rank}
</span>{" "}
{scoreStats.accuracy.toFixed(2)}%
</p>
</div>
);
}

View File

@ -0,0 +1,46 @@
import { useOverlayDataStore } from "@/store/overlayDataStore";
import { songDifficultyToColor } from "@/utils/songUtils";
import useStore from "@/utils/useStore";
import clsx from "clsx";
import Image from "next/image";
export default function SongInfo() {
const dataStore = useStore(useOverlayDataStore, (store) => store);
if (!dataStore) return null;
const { paused, songInfo } = dataStore;
if (!songInfo) return null;
return (
<div
className={clsx(
"flex transform-gpu gap-2 p-2 transition-all",
paused ? "grayscale" : "grayscale-0", // make the song info grayscale when paused
)}
>
<Image
className="rounded-lg"
alt="Song Image"
src={songInfo.art}
width={140}
height={140}
/>
<div className="flex flex-col justify-between pb-2 pt-1">
<div>
<p className="text-2xl font-bold">{songInfo.songName}</p>
<p className="text-2xl text-gray-300">{songInfo.songMapper}</p>
</div>
<div className="mt-1 flex items-center gap-2">
<p
className="rounded-md p-[3px] text-xl"
style={{
backgroundColor: songDifficultyToColor(songInfo.difficulty),
}}
>
{songInfo.difficulty}
</p>
<p className="text-xl">!bsr {songInfo.bsr}</p>
</div>
</div>
</div>
);
}

View File

@ -1,5 +1,9 @@
"use client";
import { ScoresaberPlayer } from "@/schemas/scoresaber/player";
import { formatNumber } from "@/utils/numberUtils";
// chartjs
import {
CategoryScale,
Chart as ChartJS,
@ -41,12 +45,20 @@ export const options: any = {
maxTicksLimit: 8,
stepSize: 1,
},
grid: {
// gray grid lines
color: "#252525",
},
reverse: true,
},
x: {
ticks: {
autoSkip: true,
},
grid: {
// gray grid lines
color: "#252525",
},
},
},
elements: {
@ -69,7 +81,7 @@ export const options: any = {
label(context: any) {
switch (context.dataset.label) {
case "Rank": {
return `Rank #${formatNumber(context.parsed.y.toFixed(0))}`;
return `Rank #${formatNumber(Number(context.parsed.y))}`;
}
}
},

View File

@ -1,117 +1,26 @@
import { ScoresaberPlayer } from "@/schemas/scoresaber/player";
import { useScoresaberScoresStore } from "@/store/scoresaberScoresStore";
import { useSettingsStore } from "@/store/settingsStore";
import { formatNumber } from "@/utils/numberUtils";
import { getAveragePp, getHighestPpPlay } from "@/utils/scoresaber/scores";
import { normalizedRegionName } from "@/utils/utils";
import {
GlobeAsiaAustraliaIcon,
HomeIcon,
UserIcon,
XMarkIcon,
} from "@heroicons/react/20/solid";
import { GlobeAsiaAustraliaIcon } from "@heroicons/react/20/solid";
import dynamic from "next/dynamic";
import { useRef } from "react";
import { toast } from "react-toastify";
import { useStore } from "zustand";
import { Fragment, Suspense } from "react";
import Avatar from "../Avatar";
import Button from "../Button";
import Card from "../Card";
import CountyFlag from "../CountryFlag";
import Label from "../Label";
const PPGainLabel = dynamic(() => import("./PPGainLabel"));
const PlayerInfoExtraLabels = dynamic(() => import("./PlayerInfoExtraLabels"));
const PlayerSettingsButtons = dynamic(() => import("./PlayerSettingsButtons"));
type PlayerInfoProps = {
playerData: ScoresaberPlayer;
};
export default function PlayerInfo({ playerData }: PlayerInfoProps) {
const playerId = playerData.id;
const settingsStore = useStore(useSettingsStore, (store) => store);
const playerScoreStore = useStore(useScoresaberScoresStore, (store) => store);
// Whether we have scores for this player in the local database
const hasLocalScores = playerScoreStore?.exists(playerId);
const toastId: any = useRef(null);
async function claimProfile() {
settingsStore?.setProfile(playerData);
addProfile(false);
}
async function addFriend() {
const friend = await settingsStore?.addFriend(playerData.id);
if (!friend) {
toast.error(`Failed to add ${playerData.name} as a friend`);
return;
}
addProfile(true);
}
async function removeFriend() {
settingsStore?.removeFriend(playerData.id);
toast.success(`Successfully removed ${playerData.name} as a friend`);
}
async function addProfile(isFriend: boolean) {
if (!useScoresaberScoresStore.getState().exists(playerId)) {
if (!isFriend) {
toast.success(`Successfully set ${playerData.name} as your profile`);
} else {
toast.success(`Successfully added ${playerData.name} as a friend`);
}
const reponse = await playerScoreStore?.addOrUpdatePlayer(
playerId,
(page, totalPages) => {
const autoClose = page == totalPages ? 5000 : false;
if (page == 1) {
toastId.current = toast.info(
`Fetching scores for ${playerData.name} page ${page}/${totalPages}`,
{
autoClose: autoClose,
progress: page / totalPages,
},
);
} else {
if (page != totalPages) {
toast.update(toastId.current, {
progress: page / totalPages,
render: `Fetching scores for ${playerData.name} page ${page}/${totalPages}`,
autoClose: autoClose,
});
} else {
toast.update(toastId.current, {
progress: 0,
render: `Successfully fetched scores for ${playerData.name}`,
autoClose: autoClose,
type: "success",
});
}
}
console.log(
`Fetching scores for ${playerId} (${page}/${totalPages})`,
);
},
);
if (reponse?.error) {
toast.error("Failed to fetch scores");
console.log(reponse.message);
return;
}
}
}
const isOwnProfile = settingsStore.player?.id == playerId;
const scoreStats = playerData.scoreStats;
return (
<Card className="mt-2">
<Card outerClassName="mt-2" className="mt-2">
{/* Player Info */}
<div className="flex flex-col items-center gap-3 md:flex-row md:items-start">
<div className="min-w-fit">
@ -122,33 +31,11 @@ export default function PlayerInfo({ playerData }: PlayerInfoProps) {
{/* Settings Buttons */}
<div className="absolute right-3 top-20 flex flex-col justify-end gap-2 md:relative md:right-0 md:top-0 md:mt-2 md:flex-row md:justify-center">
{!isOwnProfile && (
<Button
onClick={claimProfile}
tooltip={<p>Set as your Profile</p>}
icon={<HomeIcon width={24} height={24} />}
/>
)}
{!isOwnProfile && (
<>
{!settingsStore?.isFriend(playerId) && (
<Button
onClick={addFriend}
tooltip={<p>Add as Friend</p>}
icon={<UserIcon width={24} height={24} />}
/>
)}
{settingsStore.isFriend(playerId) && (
<Button
onClick={removeFriend}
tooltip={<p>Remove Friend</p>}
icon={<XMarkIcon width={24} height={24} />}
/>
)}
</>
)}
<Fragment>
<Suspense>
<PlayerSettingsButtons playerData={playerData} />
</Suspense>
</Fragment>
</div>
</div>
@ -163,7 +50,10 @@ export default function PlayerInfo({ playerData }: PlayerInfoProps) {
<a
className="flex transform-gpu items-center gap-1 transition-all hover:text-blue-500"
href={`/ranking/global/${Math.round(playerData.rank / 50)}`}
href={`/ranking/global/${Math.max(
Math.round(playerData.rank / 50),
1,
)}`}
>
<p>#{formatNumber(playerData.rank)}</p>
</a>
@ -173,8 +63,9 @@ export default function PlayerInfo({ playerData }: PlayerInfoProps) {
<div className="text-gray-300">
<a
className="flex transform-gpu items-center gap-1 transition-all hover:text-blue-500"
href={`/ranking/country/${playerData.country}/${Math.round(
playerData.countryRank / 50,
href={`/ranking/country/${playerData.country}/${Math.max(
Math.round(playerData.countryRank / 50),
1,
)}`}
>
<CountyFlag
@ -241,30 +132,11 @@ export default function PlayerInfo({ playerData }: PlayerInfoProps) {
value={formatNumber(scoreStats.replaysWatched)}
/>
{hasLocalScores && (
<>
<Label
title="Top PP"
className="bg-pp-blue"
tooltip={<p>Their highest pp play</p>}
value={`${formatNumber(
getHighestPpPlay(playerId)?.toFixed(2),
)}pp`}
/>
<Label
title="Avg PP"
className="bg-pp-blue"
tooltip={
<p>Average amount of pp per play (best 50 scores)</p>
}
value={`${formatNumber(
getAveragePp(playerId)?.toFixed(2),
)}pp`}
/>
<PPGainLabel playerId={playerId} />
</>
)}
<Fragment>
<Suspense>
<PlayerInfoExtraLabels playerId={playerData.id} />
</Suspense>
</Fragment>
</div>
</div>
</div>

View File

@ -0,0 +1,42 @@
"use client";
import { useScoresaberScoresStore } from "@/store/scoresaberScoresStore";
import { formatNumber } from "@/utils/numberUtils";
import { getAveragePp, getHighestPpPlay } from "@/utils/scoresaber/scores";
import { useStore } from "zustand";
import Label from "../Label";
import PPGainLabel from "./PPGainLabel";
type PlayerInfoExtraLabelsProps = {
playerId: string;
};
export default function PlayerInfoExtraLabels({
playerId,
}: PlayerInfoExtraLabelsProps) {
const playerScoreStore = useStore(useScoresaberScoresStore, (store) => store);
const hasLocalScores = playerScoreStore.exists(playerId);
if (!hasLocalScores) {
return null;
}
return (
<>
<Label
title="Top PP"
className="bg-pp-blue"
tooltip={<p>Their highest pp play</p>}
value={`${formatNumber(getHighestPpPlay(playerId)?.toFixed(2))}pp`}
/>
<Label
title="Avg PP"
className="bg-pp-blue"
tooltip={<p>Average amount of pp per play (best 50 scores)</p>}
value={`${formatNumber(getAveragePp(playerId)?.toFixed(2))}pp`}
/>
<PPGainLabel playerId={playerId} />
</>
);
}

View File

@ -1,131 +0,0 @@
"use client";
import Card from "@/components/Card";
import Container from "@/components/Container";
import Spinner from "@/components/Spinner";
import Scores from "@/components/player/Scores";
import { ScoresaberPlayer } from "@/schemas/scoresaber/player";
import { SortTypes } from "@/types/SortTypes";
import { ScoreSaberAPI } from "@/utils/scoresaber/api";
import clsx from "clsx";
import dynamic from "next/dynamic";
import Image from "next/image";
import { useEffect, useState } from "react";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/Tooltip";
import PlayerChart from "./PlayerChart";
import PlayerInfo from "./PlayerInfo";
const Error = dynamic(() => import("@/components/Error"));
type PlayerInfo = {
loading: boolean;
player: ScoresaberPlayer | undefined;
};
type PlayerPageProps = {
id: string;
sort: string;
page: string;
};
const DEFAULT_SORT_TYPE = SortTypes.top;
export default function PlayerPage({ id, sort, page }: PlayerPageProps) {
const [mounted, setMounted] = useState(false);
const [error, setError] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const [player, setPlayer] = useState<PlayerInfo>({
loading: true,
player: undefined,
});
const sortType = SortTypes[sort] || DEFAULT_SORT_TYPE;
useEffect(() => {
setMounted(true);
if (error || !player.loading) {
return;
}
if (mounted == true) {
return;
}
ScoreSaberAPI.fetchPlayerData(id).then((playerResponse) => {
if (!playerResponse) {
setError(true);
setErrorMessage("Failed to fetch player. Is the ID correct?");
setPlayer({ ...player, loading: false });
return;
}
setPlayer({ ...player, player: playerResponse, loading: false });
});
}, [error, mounted, id, player]);
if (player.loading || error || !player.player) {
return (
<main>
<Container>
<Card className="mt-2">
<div className="p-3 text-center">
<div role="status">
<div className="flex flex-col items-center justify-center gap-2">
{error && <Error errorMessage={errorMessage} />}
{!error && <Spinner />}
</div>
</div>
</div>
</Card>
</Container>
</main>
);
}
const playerData = player.player;
const badges = playerData.badges;
return (
<main>
<Container>
<PlayerInfo playerData={playerData} />
{/* Chart */}
<Card className="mt-2">
{/* Badges */}
<div
className={clsx(
"mb-2 mt-2 flex flex-wrap items-center justify-center gap-2",
badges.length > 0 ? "block" : "hidden",
)}
>
{badges.map((badge) => {
return (
<Tooltip key={badge.image}>
<TooltipTrigger>
<Image
src={badge.image}
alt={badge.description}
width={80}
height={30}
/>
</TooltipTrigger>
<TooltipContent>
<p>{badge.description}</p>
</TooltipContent>
</Tooltip>
);
})}
</div>
<div className="h-[320px] w-full">
<PlayerChart scoresaber={playerData} />
</div>
</Card>
<Scores
playerData={playerData}
page={Number(page)}
sortType={sortType}
/>
</Container>
</main>
);
}

View File

@ -8,25 +8,26 @@ import CountyFlag from "../CountryFlag";
type PlayerRankingProps = {
player: ScoresaberPlayer;
showCountryFlag?: boolean;
isCountry?: boolean;
};
const Avatar = dynamic(() => import("@/components/Avatar"));
export default function PlayerRanking({
player,
showCountryFlag,
isCountry,
}: PlayerRankingProps) {
const settingsStore = useStore(useSettingsStore, (store) => store);
return (
<>
<td className="px-4 py-2">#{formatNumber(player.rank)}</td>
<td className="px-4 py-2">
#{formatNumber(isCountry ? player.countryRank : player.rank)}{" "}
<span className="text-sm">{isCountry && "(#" + player.rank + ")"}</span>
</td>
<td className="flex items-center gap-2 px-4 py-2">
<Avatar url={player.profilePicture} label="Avatar" size={24} />
{showCountryFlag && (
<CountyFlag countryCode={player.country} className="!h-5 !w-5" />
)}
<CountyFlag countryCode={player.country} className="!h-5 !w-5" />
<Link
className="transform-gpu transition-all hover:text-blue-500"
href={`/player/${player.id}/top/1`}

View File

@ -1,54 +0,0 @@
import { ScoresaberPlayer } from "@/schemas/scoresaber/player";
import { useSettingsStore } from "@/store/settingsStore";
import { formatNumber } from "@/utils/numberUtils";
import { useStore } from "zustand";
import Avatar from "../Avatar";
import CountyFlag from "../CountryFlag";
import Label from "../Label";
type PlayerRankingProps = {
player: ScoresaberPlayer;
showCountryFlag?: boolean;
};
export default function PlayerRankingMobile({
player,
showCountryFlag = true,
}: PlayerRankingProps) {
const settingsStore = useStore(useSettingsStore, (store) => store);
return (
<div>
<div className="m-3 flex flex-col gap-2">
<p className="flex items-center gap-2">
<p>#{formatNumber(player.rank)}</p>
<Avatar url={player.profilePicture} label="Avatar" size={24} />
{showCountryFlag && (
<CountyFlag countryCode={player.country} className="!h-5 !w-5" />
)}
<p
className={
player.id == settingsStore.player?.id
? "transform-gpu text-red-500 transition-all hover:text-blue-500"
: ""
}
>
{player.name}
</p>
</p>
<div className="flex flex-wrap justify-center gap-2">
<Label
title="PP"
tooltip={<p>The total amount of pp this player has</p>}
value={`${formatNumber(player.pp)}`}
/>
<Label
title="Total play count"
tooltip={<p>The total amount of plays this player has</p>}
value={formatNumber(player.scoreStats.totalPlayCount)}
/>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,137 @@
"use client";
import { ScoresaberPlayer } from "@/schemas/scoresaber/player";
import { useScoresaberScoresStore } from "@/store/scoresaberScoresStore";
import { useSettingsStore } from "@/store/settingsStore";
import { HomeIcon, UserIcon, XMarkIcon } from "@heroicons/react/20/solid";
import { useEffect, useRef, useState } from "react";
import { toast } from "react-toastify";
import { useStore } from "zustand";
import Button from "../Button";
type PlayerSettingsButtonsProps = {
playerData: ScoresaberPlayer;
};
export default function PlayerSettingsButtons({
playerData,
}: PlayerSettingsButtonsProps) {
const [mounted, setMounted] = useState(false);
const playerId = playerData.id;
const settingsStore = useStore(useSettingsStore, (store) => store);
const playerScoreStore = useStore(useScoresaberScoresStore, (store) => store);
const isOwnProfile = settingsStore.player?.id == playerId;
const toastId: any = useRef(null);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted || isOwnProfile) {
return null;
}
async function claimProfile() {
settingsStore?.setProfile(playerData);
addProfile(false);
}
async function addFriend() {
const friend = await settingsStore?.addFriend(playerData.id);
if (!friend) {
toast.error(`Failed to add ${playerData.name} as a friend`);
return;
}
addProfile(true);
}
async function removeFriend() {
settingsStore?.removeFriend(playerData.id);
toast.success(`Successfully removed ${playerData.name} as a friend`);
}
async function addProfile(isFriend: boolean) {
if (!useScoresaberScoresStore.getState().exists(playerId)) {
if (!isFriend) {
toast.success(`Successfully set ${playerData.name} as your profile`);
} else {
toast.success(`Successfully added ${playerData.name} as a friend`);
}
const reponse = await playerScoreStore?.addOrUpdatePlayer(
playerId,
(page, totalPages) => {
const autoClose = page == totalPages ? 5000 : false;
if (page == 1) {
toastId.current = toast.info(
`Fetching scores for ${playerData.name} page ${page}/${totalPages}`,
{
autoClose: autoClose,
progress: page / totalPages,
},
);
} else {
if (page != totalPages) {
toast.update(toastId.current, {
progress: page / totalPages,
render: `Fetching scores for ${playerData.name} page ${page}/${totalPages}`,
autoClose: autoClose,
});
} else {
toast.update(toastId.current, {
progress: 0,
render: `Successfully fetched scores for ${playerData.name}`,
autoClose: autoClose,
type: "success",
});
}
}
console.log(
`Fetching scores for ${playerId} (${page}/${totalPages})`,
);
},
);
if (reponse?.error) {
toast.error("Failed to fetch scores");
console.log(reponse.message);
return;
}
}
}
return (
<>
<Button
onClick={claimProfile}
tooltip={<p>Set as your Profile</p>}
icon={<HomeIcon width={24} height={24} />}
ariaLabel="Set as your Profile"
/>
{!settingsStore?.isFriend(playerId) && (
<Button
onClick={addFriend}
tooltip={<p>Add as Friend</p>}
icon={<UserIcon width={24} height={24} />}
color="bg-green-500"
ariaLabel="Add Friend"
/>
)}
{settingsStore.isFriend(playerId) && (
<Button
onClick={removeFriend}
tooltip={<p>Remove Friend</p>}
icon={<XMarkIcon width={24} height={24} />}
color="bg-red-500"
ariaLabel="Remove Friend"
/>
)}
</>
);
}

View File

@ -1,33 +1,39 @@
"use client";
import { ScoresaberPlayer } from "@/schemas/scoresaber/player";
import { ScoresaberPlayerScore } from "@/schemas/scoresaber/playerScore";
import { useSettingsStore } from "@/store/settingsStore";
import { SortType, SortTypes } from "@/types/SortTypes";
import { ScoreSaberAPI } from "@/utils/scoresaber/api";
import useStore from "@/utils/useStore";
import dynamic from "next/dynamic";
import { useCallback, useEffect, useState } from "react";
import Card from "../Card";
import Error from "../Error";
import Pagination from "../Pagination";
import Score from "./Score";
const Spinner = dynamic(() => import("@/components/Spinner"));
import Score from "./score/Score";
type PageInfo = {
loading: boolean;
page: number;
totalPages: number;
sortType: SortType;
scores: ScoresaberPlayerScore[];
scores: ScoresaberPlayerScore[] | undefined;
};
type ScoresProps = {
initalScores: ScoresaberPlayerScore[] | undefined;
initalPage: number;
initalSortType: SortType;
initalTotalPages?: number;
playerData: ScoresaberPlayer;
page: number;
sortType: SortType;
};
export default function Scores({ playerData, page, sortType }: ScoresProps) {
export default function Scores({
initalScores,
initalPage,
initalSortType,
initalTotalPages,
playerData,
}: ScoresProps) {
const settingsStore = useStore(useSettingsStore, (store) => store);
const playerId = playerData.id;
@ -36,28 +42,37 @@ export default function Scores({ playerData, page, sortType }: ScoresProps) {
const [errorMessage, setErrorMessage] = useState("");
const [scores, setScores] = useState<PageInfo>({
loading: true,
page: page,
totalPages: 1,
sortType: sortType,
scores: [],
page: initalPage,
totalPages: initalTotalPages || 1,
sortType: initalSortType,
scores: initalScores,
});
const [changedPage, setChangedPage] = useState(false);
const updateScoresPage = useCallback(
(sortType: SortType, page: any) => {
if (
page == initalPage &&
sortType == initalSortType &&
initalScores &&
!changedPage
) {
console.log("Already loaded scores, not fetching");
return;
}
ScoreSaberAPI.fetchScores(playerId, page, sortType.value, 10).then(
(scoresResponse) => {
if (!scoresResponse) {
setError(true);
setErrorMessage("No Scores");
setScores({ ...scores, loading: false });
setScores({ ...scores });
return;
}
setScores({
...scores,
scores: scoresResponse.scores,
totalPages: scoresResponse.pageInfo.totalPages,
loading: false,
page: page,
sortType: sortType,
});
@ -67,12 +82,21 @@ export default function Scores({ playerData, page, sortType }: ScoresProps) {
"",
`/player/${playerId}/${sortType.value}/${page}`,
);
setChangedPage(true);
console.log(`Switched page to ${page} with sort ${sortType.value}`);
},
);
},
[playerId, scores, settingsStore],
[
changedPage,
initalPage,
initalScores,
initalSortType,
playerId,
scores,
settingsStore,
],
);
useEffect(() => {
@ -97,9 +121,9 @@ export default function Scores({ playerData, page, sortType }: ScoresProps) {
}
return (
<Card className="mt-2 w-full items-center md:flex-col">
<Card outerClassName="mt-2" className="w-full items-center md:flex-col">
{/* Sort */}
<div className="m-4 w-full text-sm">
<div className="mb-2 mt-1 w-full text-sm">
<div className="flex justify-center gap-2">
{Object.values(SortTypes).map((sortType) => {
return (
@ -122,45 +146,39 @@ export default function Scores({ playerData, page, sortType }: ScoresProps) {
</div>
</div>
<div className="w-full p-1">
{scores.loading ? (
<div className="flex justify-center">
<Spinner />
</div>
) : (
<div className="grid grid-cols-1 divide-y divide-gray-800">
{!scores.loading && scores.scores.length == 0 ? (
<p className="text-red-400">{errorMessage}</p>
) : (
scores.scores.map((scoreData, id) => {
const { score, leaderboard } = scoreData;
<div className="flex h-full w-full flex-col items-center justify-center">
<>
{scores.scores ? (
<>
<div className="grid min-w-full grid-cols-1 divide-y divide-border">
{scores.scores.map((scoreData, id) => {
const { score, leaderboard } = scoreData;
return (
<Score
key={id}
player={playerData}
score={score}
leaderboard={leaderboard}
ownProfile={settingsStore?.player}
/>
);
})
)}
</div>
)}
</div>
{/* Pagination */}
<div>
<div className="pt-3">
<Pagination
currentPage={scores.page}
totalPages={scores.totalPages}
onPageChange={(page) => {
updateScoresPage(scores.sortType, page);
}}
/>
</div>
return (
<Score
key={id}
player={playerData}
score={score}
leaderboard={leaderboard}
/>
);
})}
</div>
{/* Pagination */}
<div className="pt-3">
<Pagination
currentPage={scores.page}
totalPages={scores.totalPages}
onPageChange={(page) => {
updateScoresPage(scores.sortType, page);
}}
/>
</div>
</>
) : (
<p>No Scores!</p>
)}
</>
</div>
</Card>
);

View File

@ -0,0 +1,34 @@
"use client";
import { toast } from "react-toastify";
import { Tooltip, TooltipContent, TooltipTrigger } from "../../ui/Tooltip";
import { Button } from "../../ui/button";
type CopyBsrButtonProps = {
mapId: string;
};
export default function CopyBsrButton({ mapId }: CopyBsrButtonProps) {
return (
<Tooltip>
<TooltipTrigger>
<Button
className="h-[30px] w-[30px] bg-neutral-700 p-1"
variant={"secondary"}
onClick={() => {
navigator.clipboard.writeText(`!bsr ${mapId}`);
toast.success("Copied BSR code to clipboard");
}}
>
<p>!</p>
</Button>
</TooltipTrigger>
<TooltipContent>
<div>
<p>Click to copy the BSR code</p>
<p>!bsr {mapId}</p>
</div>
</TooltipContent>
</Tooltip>
);
}

View File

@ -0,0 +1,98 @@
"use client";
import BeatSaverLogo from "@/components/icons/BeatSaverLogo";
import YouTubeLogo from "@/components/icons/YouTubeLogo";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/Tooltip";
import { Button } from "@/components/ui/button";
import { ScoresaberLeaderboardInfo } from "@/schemas/scoresaber/leaderboard";
import { songNameToYouTubeLink } from "@/utils/songUtils";
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
import CopyBsrButton from "./CopyBsrButton";
type MapButtonsProps = {
leaderboard: ScoresaberLeaderboardInfo;
};
export default function MapButtons({ leaderboard }: MapButtonsProps) {
const [mapId, setMapId] = useState<string | undefined>(undefined);
const hash = leaderboard.songHash;
const getMapId = useCallback(async () => {
const beatSaberMap = await fetch(
`/api/beatsaver/mapdata?hashes=${hash}&idonly=true`,
);
if (!beatSaberMap) {
return;
}
const json = await beatSaberMap.json();
if (json.maps[hash] == null || json.maps[hash] == undefined) {
return;
}
console.log(json);
setMapId(json.maps[hash].id);
}, [hash]);
useEffect(() => {
getMapId();
}, [getMapId]);
return (
<div className="hidden flex-col items-center gap-2 p-1 md:flex md:items-start">
{mapId && (
<>
<div className="flex gap-2">
<Tooltip>
<TooltipTrigger>
<Link
href={`https://beatsaver.com/maps/${mapId}`}
target="_blank"
>
<Button
className="h-[30px] w-[30px] bg-neutral-700 p-1"
variant={"secondary"}
>
<BeatSaverLogo size={20} />
</Button>
</Link>
</TooltipTrigger>
<TooltipContent>
<p>Click to open the map page</p>
</TooltipContent>
</Tooltip>
<CopyBsrButton mapId={mapId} />
</div>
<div className="flex gap-2">
<Tooltip>
<TooltipTrigger>
<Link
href={`${songNameToYouTubeLink(
leaderboard.songName,
leaderboard.songSubName,
leaderboard.songAuthorName,
)}`}
target="_blank"
>
<Button
className="h-[30px] w-[30px] bg-neutral-700 p-1"
variant={"secondary"}
>
<YouTubeLogo size={20} />
</Button>
</Link>
</TooltipTrigger>
<TooltipContent>
<p>Click to view the song on YouTube</p>
</TooltipContent>
</Tooltip>
</div>
</>
)}
</div>
);
}

View File

@ -17,23 +17,19 @@ import {
import clsx from "clsx";
import Image from "next/image";
import Link from "next/link";
import HeadsetIcon from "../icons/HeadsetIcon";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/Tooltip";
import ScoreStatLabel from "./ScoreStatLabel";
import { Suspense } from "react";
import HeadsetIcon from "../../icons/HeadsetIcon";
import { Tooltip, TooltipContent, TooltipTrigger } from "../../ui/Tooltip";
import ScoreStatLabel from "../ScoreStatLabel";
import MapButtons from "./MapButtons";
type ScoreProps = {
score: ScoresaberScore;
player: ScoresaberPlayer;
leaderboard: ScoresaberLeaderboardInfo;
ownProfile?: ScoresaberPlayer;
};
export default function Score({
score,
player,
leaderboard,
ownProfile,
}: ScoreProps) {
export default function Score({ score, player, leaderboard }: ScoreProps) {
const isFullCombo = score.missedNotes + score.badCuts === 0;
const diffName = scoresaberDifficultyNumberToName(
leaderboard.difficulty.difficulty,
@ -44,7 +40,7 @@ export default function Score({
const weightedPp = formatNumber(getPpGainedFromScore(player.id, score), 2);
return (
<div className="grid grid-cols-1 pb-2 pt-2 first:pt-0 last:pb-0 md:grid-cols-[0.85fr_6fr_1.3fr]">
<div className="grid grid-cols-1 pb-2 pt-2 first:pt-0 last:pb-0 md:grid-cols-[0.85fr_6fr_0.5fr_1.3fr]">
<div className="flex flex-col items-center justify-center">
<div className="hidden w-fit flex-row items-center justify-center gap-1 md:flex">
<GlobeAsiaAustraliaIcon width={20} height={20} />
@ -74,7 +70,7 @@ export default function Score({
className="h-fit min-w-[60px] rounded-md"
width={60}
height={60}
loading="lazy"
priority
/>
<div
className="absolute mt-12 flex w-[55px] cursor-default items-center justify-center divide-x divide-y rounded-sm pl-[3px] pr-[3px] text-[0.8rem] opacity-90"
@ -91,10 +87,10 @@ export default function Score({
</div>
</TooltipTrigger>
<TooltipContent>
<div>
<>
<p className="font-bold">Difficulty</p>
<p>{diffName}</p>
</div>
</>
</TooltipContent>
</Tooltip>
) : (
@ -117,6 +113,10 @@ export default function Score({
</Link>
</div>
<Suspense fallback={<div />}>
<MapButtons leaderboard={leaderboard} />
</Suspense>
<div className="flex items-center justify-between p-1 md:items-start md:justify-end">
<div className="flex flex-col md:hidden">
{/* Score rank */}
@ -149,7 +149,7 @@ export default function Score({
<div className="flex justify-end gap-2">
{score.pp > 0 && (
<ScoreStatLabel
className="bg-blue-500 text-center"
className="bg-pp-blue text-center"
value={formatNumber(score.pp.toFixed(2)) + "pp"}
tooltip={
<div>

View File

@ -18,7 +18,7 @@ const TooltipContent = React.forwardRef<
ref={ref}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 overflow-hidden rounded-md border px-3 py-1.5 text-sm shadow-md",
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}

View File

@ -0,0 +1,56 @@
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
import { cn } from "@/utils/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
},
);
Button.displayName = "Button";
export { Button, buttonVariants };

View File

@ -1,6 +1,6 @@
import * as React from "react"
import * as React from "react";
import { cn } from "@/utils/utils"
import { cn } from "@/utils/utils";
const Card = React.forwardRef<
HTMLDivElement,
@ -10,12 +10,12 @@ const Card = React.forwardRef<
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
className,
)}
{...props}
/>
))
Card.displayName = "Card"
));
Card.displayName = "Card";
const CardHeader = React.forwardRef<
HTMLDivElement,
@ -26,8 +26,8 @@ const CardHeader = React.forwardRef<
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
));
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<
HTMLParagraphElement,
@ -37,12 +37,12 @@ const CardTitle = React.forwardRef<
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
className,
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
));
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<
HTMLParagraphElement,
@ -53,16 +53,16 @@ const CardDescription = React.forwardRef<
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
));
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<
HTMLDivElement,
@ -73,7 +73,14 @@ const CardFooter = React.forwardRef<
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
));
CardFooter.displayName = "CardFooter";
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
export {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
};

View File

@ -0,0 +1,25 @@
import * as React from "react"
import { cn } from "@/utils/utils"
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

View File

@ -0,0 +1,26 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/utils/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@ -0,0 +1,44 @@
"use client"
import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { Circle } from "lucide-react"
import { cn } from "@/utils/utils"
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Root
className={cn("grid gap-2", className)}
{...props}
ref={ref}
/>
)
})
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, children, ...props }, ref) => {
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<Circle className="h-2.5 w-2.5 fill-current text-current" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
})
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
export { RadioGroup, RadioGroupItem }

View File

@ -0,0 +1,29 @@
"use client"
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/utils/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-[24px] w-[44px] shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

26
src/lib/db/redis.ts Normal file
View File

@ -0,0 +1,26 @@
import { createClient } from "redis";
let redisClient = await connectRedis();
async function connectRedis(): Promise<any> {
// console.log("Connecting to redis");
const client = createClient({
url: process.env.REDIS_URL,
});
await client.connect();
console.log("Connected to redis");
client.on("error", (error) => {
console.error("There was an error connecting to redis: " + error);
setTimeout(async () => {
redisClient = await connectRedis();
}, 30_000); // 30 seconds
});
return client;
}
export const Redis = {
client: redisClient,
connectRedis,
};

View File

@ -0,0 +1,133 @@
import { useOverlayDataStore } from "@/store/overlayDataStore";
import { useOverlaySettingsStore } from "@/store/overlaySettingsStore";
import { BeatsaverAPI } from "@/utils/beatsaver/api";
import { w3cwebsocket as WebSocketClient } from "websocket";
const settingsStore = useOverlaySettingsStore;
const overlayDataStore = useOverlayDataStore;
let isConnected = false;
async function loadIntoSong(data: any) {
const { beatmap, performance } = data;
const hash = beatmap.songHash;
const beatsaverMapData = await BeatsaverAPI.fetchMapByHash(hash);
if (beatsaverMapData == undefined) return;
const coverURL =
beatsaverMapData.versions[beatsaverMapData.versions.length - 1].coverURL;
overlayDataStore.setState({
paused: false,
scoreStats: {
accuracy: performance.relativeScore * 100,
score: performance.rawScore,
combo: performance.combo,
rank: performance.rank,
},
songInfo: {
art: coverURL,
bsr: beatsaverMapData.id,
difficulty: beatmap.difficulty,
songMapper: beatsaverMapData.metadata.levelAuthorName,
songName: beatsaverMapData.metadata.songName,
songSubName: beatsaverMapData.metadata.songSubName,
},
});
}
/**
* Reset the state of the overlay
*/
function resetState() {
overlayDataStore.setState({
scoreStats: undefined,
songInfo: undefined,
});
}
type Handlers = {
[key: string]: (data: any) => void;
};
const handlers: Handlers = {
hello: async (data: any) => {
if (!data.beatmap || !data.performance) {
return;
}
loadIntoSong(data);
},
songStart: (data: any) => {
loadIntoSong(data);
},
scoreChanged: (data: any) => {
const { performance } = data;
if (performance == undefined) return;
overlayDataStore.setState({
scoreStats: {
accuracy: performance.relativeScore * 100,
score: performance.rawScore,
combo: performance.combo,
rank: performance.rank,
},
});
},
// Left the song
finished: (data: any) => {
resetState();
},
menu: (data: any) => {
resetState();
},
// pause & resume
pause: (data: any) => {
overlayDataStore.setState({
paused: true,
});
},
resume: (data: any) => {
overlayDataStore.setState({
paused: false,
});
},
};
function connectWebSocket() {
if (isConnected) return;
const client = new WebSocketClient(
`ws://${settingsStore.getState().ipAddress}:6557/socket`,
);
isConnected = true;
client.onopen = () => {
console.log("WebSocket Connected to HttpSiraStatus");
};
client.onerror = (error) => {
console.error(error);
};
client.onclose = () => {
isConnected = false;
console.log(
"Lost connection to HttpSiraStatus, reconnecting in 5 seconds...",
);
resetState();
setTimeout(() => {
connectWebSocket();
}, 5000); // 5 seconds
};
client.onmessage = (message) => {
const data = JSON.parse(message.data.toString());
const handler = handlers[data.event];
//console.log(data.event);
if (handler == undefined) {
console.error(`No handler for ${data.event}`);
return;
}
handler(data.status);
};
}
export const HttpSiraStatus = {
connectWebSocket,
};

View File

@ -0,0 +1,8 @@
export type OverlayPlayer = {
id: string;
country: string;
profilePicture: string;
pp: number;
rank: number;
countryRank: number;
};

View File

@ -15,15 +15,6 @@ export function middleware(request: NextRequest) {
return NextResponse.redirect(new URL("/search", request.url));
}
}
const requestHeaders = new Headers(request.headers);
requestHeaders.set("x-url", request.url);
return NextResponse.next({
request: {
// New request headers
headers: requestHeaders,
},
});
}
export const config = {

View File

@ -0,0 +1,30 @@
import { BeatleaderModifierRating } from "./modifierRating";
import { BeatleaderModifier } from "./modifiers";
export type BeatleaderDifficulty = {
id: number;
value: number;
mode: number;
difficultyName: string;
modeName: string;
status: number;
modifierValues: BeatleaderModifier;
modifiersRating: BeatleaderModifierRating;
nominatedTime: number;
qualifiedTime: number;
rankedTime: number;
stars: number;
predictedAcc: number;
passRating: number;
accRating: number;
techRating: number;
type: number;
njs: number;
nps: number;
notes: number;
bombs: number;
walls: number;
maxScore: number;
duration: number;
requirements: number;
};

View File

@ -0,0 +1,16 @@
import { BeatleaderDifficulty } from "./difficulty";
import { BeatleaderSong } from "./song";
export type BeatleaderLeaderboard = {
id: string;
song: BeatleaderSong;
difficulty: BeatleaderDifficulty;
scores: null; // ??
changes: null; // ??
qualification: null; // ??
reweight: null; // ??
leaderboardGroup: null; // ??
plays: number;
clan: null; // ??
clanRankingContested: boolean;
};

View File

@ -0,0 +1,5 @@
export type BeatleaderMetadata = {
itemsPerPage: number;
page: number;
total: number;
};

View File

@ -0,0 +1,18 @@
export type BeatleaderModifierRating = {
id: number;
fsPredictedAcc: number;
fsPassRating: number;
fsAccRating: number;
fsTechRating: number;
fsStars: number;
ssPredictedAcc: number;
ssPassRating: number;
ssAccRating: number;
ssTechRating: number;
ssStars: number;
sfPredictedAcc: number;
sfPassRating: number;
sfAccRating: number;
sfTechRating: number;
sfStars: number;
};

View File

@ -0,0 +1,16 @@
export type BeatleaderModifier = {
modifierId: number;
da: number;
fs: number;
sf: number;
ss: number;
gn: number;
na: number;
nb: number;
nf: number;
no: number;
pm: number;
sc: number;
sa: number;
op: number;
};

View File

@ -0,0 +1,9 @@
export type BeatLeaderPlayer = {
id: string;
country: string;
avatar: string;
pp: number;
rank: number;
countryRank: number;
// todo: finish this
};

View File

@ -0,0 +1,51 @@
import { BeatleaderLeaderboard } from "./leaderboard";
import { BeatleaderScoreImprovement } from "./scoreImprovement";
import { BeatleaderScoreOffsets } from "./scoreOffsets";
export type BeatleaderScore = {
myScore: null; // ??
validContexts: number;
leaderboard: BeatleaderLeaderboard;
contextExtensions: null; // ??
accLeft: number;
accRight: number;
id: number;
baseScore: number;
modifiedScore: number;
accuracy: number;
playerId: string;
pp: number;
bonusPp: number;
passPP: number;
accPP: number;
techPP: number;
rank: number;
country: string;
fcAccuracy: number;
fcPp: number;
weight: number;
replay: string;
modifiers: string;
badCuts: number;
missedNotes: number;
bombCuts: number;
wallsHit: number;
pauses: number;
fullCombo: boolean;
platform: string;
maxCombo: number;
maxStreak: number;
hmd: number;
controller: number;
leaderboardId: string;
timeset: string;
timepost: number;
replaysWatched: number;
playCount: number;
priority: number;
player: null; // ??
scoreImprovement: BeatleaderScoreImprovement;
rankVoting: null; // ??
metadata: null; // ??
offsets: BeatleaderScoreOffsets;
};

View File

@ -0,0 +1,19 @@
export type BeatleaderScoreImprovement = {
id: number;
timeset: number;
score: number;
accuracy: number;
pp: number;
bonusPp: number;
rank: number;
accRight: number;
accLeft: number;
averageRankedAccuracy: number;
totalPp: number;
totalRank: number;
badCuts: number;
missedNotes: number;
bombCuts: number;
wallsHit: number;
pauses: number;
};

View File

@ -0,0 +1,8 @@
export type BeatleaderScoreOffsets = {
id: number;
frames: number;
notes: number;
walls: number;
heights: number;
pauses: number;
};

View File

View File

@ -0,0 +1,5 @@
import { BeatleaderSmallerSong } from "./smallerSong";
export type BeatleaderSmallerLeaderboard = {
song: BeatleaderSmallerSong;
};

View File

@ -0,0 +1,14 @@
import { BeatleaderSmallerLeaderboard } from "./smallerLeaderboard";
import { BeatleaderSmallerScoreImprovement } from "./smallerScoreImprovement";
export type BeatleaderSmallerScore = {
id: number;
timepost: number;
accLeft: number;
accRight: number;
fcAccuracy: number;
wallsHit: number;
replay: string;
leaderboard: BeatleaderSmallerLeaderboard;
scoreImprovement: BeatleaderSmallerScoreImprovement | null;
};

View File

@ -0,0 +1,9 @@
export type BeatleaderSmallerScoreImprovement = {
score: number;
accuracy: number;
accRight: number;
accLeft: number;
badCuts: number;
missedNotes: number;
bombCuts: number;
};

View File

@ -0,0 +1,4 @@
export type BeatleaderSmallerSong = {
hash: string;
bpm: number;
};

View File

@ -0,0 +1,16 @@
export type BeatleaderSong = {
id: string;
hash: string;
name: string;
subName: string;
author: string;
mapperId: string;
coverImage: string;
fullCoverImage: string;
downloadUrl: string;
bpm: number;
duration: number;
tags: string;
uploadTime: number;
difficulties: null; // ??
};

View File

@ -0,0 +1,21 @@
import { BeatsaverMapMetadata } from "./BeatsaverMapMetadata";
import { BeatsaverMapStats } from "./BeatsaverMapStats";
import { BeatsaverMapVersion } from "./BeatsaverMapVersion";
import { BeatsaverUploader } from "./BeatsaverUploader";
export type BeatsaverMap = {
id: string;
name: string;
description: string;
uploader: BeatsaverUploader;
metadata: BeatsaverMapMetadata;
stats: BeatsaverMapStats;
uploaded: string;
automapper: boolean;
ranked: boolean;
qualified: boolean;
versions: BeatsaverMapVersion[];
createdAt: string;
updatedAt: string;
lastPublishedAt: string;
};

View File

@ -0,0 +1,22 @@
import { BeatsaverMapSummary } from "./BeatsaverMapSummary";
export type BeatsaverMapDifficulty = {
njs: number;
offset: number;
notes: number;
bombs: number;
obstacles: number;
nps: number;
length: number;
characteristic: string;
difficulty: string;
events: number;
chroma: boolean;
me: boolean;
ne: boolean;
cinema: boolean;
seconds: number;
paritySummary: BeatsaverMapSummary;
maxScore: number;
label: string;
};

View File

@ -0,0 +1,8 @@
export type BeatsaverMapMetadata = {
bpm: number;
duration: number;
songName: string;
songSubName: string;
songAuthorName: string;
levelAuthorName: string;
};

View File

@ -0,0 +1,7 @@
export type BeatsaverMapStats = {
plays: number;
downloads: number;
upvotes: number;
downvotes: number;
score: number;
};

View File

@ -0,0 +1,5 @@
export type BeatsaverMapSummary = {
errors: number;
warns: number;
resets: number;
};

View File

@ -0,0 +1,12 @@
import { BeatsaverMapDifficulty } from "./BeatsaverMapDifficulty";
export type BeatsaverMapVersion = {
hash: string;
state: string;
createdAt: string;
sageScore: number;
diffs: BeatsaverMapDifficulty[];
downloadURL: string;
coverURL: string;
previewURL: string;
};

View File

@ -0,0 +1,10 @@
export type BeatsaverUploader = {
id: string;
name: string;
hash: string;
avatar: string;
type: string;
admin: boolean;
curator: boolean;
playlistUrl: string;
};

View File

@ -0,0 +1,10 @@
import { ScoresaberLeaderboardInfo } from "./leaderboard";
import { ScoresaberScore } from "./score";
export type ScoresaberScoreWithBeatsaverData = {
score: ScoresaberScore;
leaderboard: ScoresaberLeaderboardInfo;
// Beatsaver data
mapId: string;
};

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