From e87d73bbdf479346f6ca882979578614610b6672 Mon Sep 17 00:00:00 2001 From: Liam Date: Wed, 9 Oct 2024 01:17:00 +0100 Subject: [PATCH] LETS GO BABY --- .gitea/kubernetes/backend/deployment.yaml | 8 +- .gitea/kubernetes/backend/ingress.yaml | 2 +- .gitea/kubernetes/backend/sealed-secrets.yaml | 16 +++ .../backend/strip-api-prefix-middleware.yaml | 2 +- .gitea/kubernetes/website/deployment.yaml | 20 --- .gitea/kubernetes/website/sealed-secrets.yaml | 4 - .gitea/workflows/deploy-backend.yml | 1 + .gitea/workflows/deploy-website.yml | 20 +-- bun.lockb | Bin 342816 -> 295112 bytes package.json | 15 ++- projects/backend/.gitignore | 4 +- projects/backend/package.json | 7 +- projects/backend/src/common/config.ts | 3 + .../src/controller/player.controller.ts | 54 ++++++++ projects/backend/src/error/not-found-error.ts | 10 ++ .../backend/src/error/rate-limit-error.ts | 5 +- projects/backend/src/index.ts | 68 +++++++--- projects/backend/src/model/player.ts | 108 +++++++++++++++ .../backend/src/service/player.service.ts | 126 ++++++++++++++++++ projects/backend/tsconfig.json | 8 +- projects/common/package.json | 13 +- projects/common/src/index.ts | 49 ------- .../common/src/service/impl/scoresaber.ts | 18 +-- .../types/player/impl/scoresaber-player.ts | 17 ++- .../src/types/player/player-tracked-since.ts | 5 - projects/common/tsconfig.json | 25 ++-- projects/common/tsup.config.ts | 10 -- projects/website/.env-example | 7 +- projects/website/Dockerfile | 29 ++-- projects/website/config.ts | 1 + projects/website/package.json | 6 +- .../app/(pages)/api/player/history/route.ts | 35 ----- .../api/player/isbeingtracked/route.ts | 22 --- .../src/app/(pages)/api/proxy/route.ts | 47 ------- .../src/app/(pages)/api/trigger/route.ts | 10 -- .../src/app/(pages)/player/[...slug]/page.tsx | 11 +- .../common/database/types/beatsaver-map.ts | 23 ++++ projects/website/src/common/mongo.ts | 12 -- projects/website/src/common/player-utils.ts | 2 +- projects/website/src/common/website-utils.ts | 9 ++ projects/website/src/common/worker/worker.ts | 2 +- .../src/components/input/search-player.tsx | 4 +- .../leaderboard/leaderboard-data.tsx | 26 ++-- .../leaderboard/leaderboard-info.tsx | 2 +- .../leaderboard/leaderboard-player.tsx | 4 +- .../leaderboard/leaderboard-score-stats.tsx | 4 +- .../leaderboard/leaderboard-score.tsx | 6 +- .../leaderboard/leaderboard-scores.tsx | 8 +- .../leaderboard-song-star-count.tsx | 2 +- .../player/chart/generic-player-chart.tsx | 4 +- .../player/chart/player-accuracy-chart.tsx | 2 +- .../components/player/chart/player-charts.tsx | 2 +- .../player/chart/player-ranking-chart.tsx | 2 +- .../src/components/player/player-badges.tsx | 2 +- .../src/components/player/player-data.tsx | 19 ++- .../src/components/player/player-scores.tsx | 8 +- .../src/components/player/player-stats.tsx | 2 +- .../player/player-tracked-status.tsx | 21 +-- .../website/src/components/ranking/mini.tsx | 8 +- .../ranking/player-ranking-skeleton.tsx | 2 +- .../src/components/score/score-badge.tsx | 4 +- .../src/components/score/score-buttons.tsx | 4 +- .../src/components/score/score-info.tsx | 2 +- .../src/components/score/score-rank-info.tsx | 4 +- .../website/src/components/score/score.tsx | 24 ++-- .../src/{app => }/components/ui/skeleton.tsx | 0 projects/website/src/jobs/index.ts | 3 - .../src/jobs/track-player-statistics.ts | 31 ----- projects/website/src/trigger.ts | 7 - 69 files changed, 583 insertions(+), 458 deletions(-) create mode 100644 .gitea/kubernetes/backend/sealed-secrets.yaml create mode 100644 projects/backend/src/common/config.ts create mode 100644 projects/backend/src/controller/player.controller.ts create mode 100644 projects/backend/src/error/not-found-error.ts create mode 100644 projects/backend/src/model/player.ts create mode 100644 projects/backend/src/service/player.service.ts delete mode 100644 projects/common/src/index.ts delete mode 100644 projects/common/tsup.config.ts delete mode 100644 projects/website/src/app/(pages)/api/player/history/route.ts delete mode 100644 projects/website/src/app/(pages)/api/player/isbeingtracked/route.ts delete mode 100644 projects/website/src/app/(pages)/api/proxy/route.ts delete mode 100644 projects/website/src/app/(pages)/api/trigger/route.ts create mode 100644 projects/website/src/common/database/types/beatsaver-map.ts delete mode 100644 projects/website/src/common/mongo.ts rename projects/website/src/{app => }/components/ui/skeleton.tsx (100%) delete mode 100644 projects/website/src/jobs/index.ts delete mode 100644 projects/website/src/jobs/track-player-statistics.ts delete mode 100644 projects/website/src/trigger.ts diff --git a/.gitea/kubernetes/backend/deployment.yaml b/.gitea/kubernetes/backend/deployment.yaml index 2478e05..bd1cf6c 100644 --- a/.gitea/kubernetes/backend/deployment.yaml +++ b/.gitea/kubernetes/backend/deployment.yaml @@ -25,4 +25,10 @@ spec: memory: 128Mi limits: cpu: 1000m # 1 vCPU - memory: 512Mi \ No newline at end of file + memory: 512Mi + env: + - name: MONGO_URI + valueFrom: + secretKeyRef: + name: ssr-backend-secret + key: MONGO_URI \ No newline at end of file diff --git a/.gitea/kubernetes/backend/ingress.yaml b/.gitea/kubernetes/backend/ingress.yaml index a134817..2de160a 100644 --- a/.gitea/kubernetes/backend/ingress.yaml +++ b/.gitea/kubernetes/backend/ingress.yaml @@ -10,7 +10,7 @@ spec: entryPoints: - websecure routes: - - match: Host(`ssr.fascinated.cc`) && PathPrefix(`/api-test`) + - match: Host(`ssr.fascinated.cc`) && PathPrefix(`/api`) kind: Rule middlewares: - name: default-headers diff --git a/.gitea/kubernetes/backend/sealed-secrets.yaml b/.gitea/kubernetes/backend/sealed-secrets.yaml new file mode 100644 index 0000000..a2d1855 --- /dev/null +++ b/.gitea/kubernetes/backend/sealed-secrets.yaml @@ -0,0 +1,16 @@ +--- +apiVersion: bitnami.com/v1alpha1 +kind: SealedSecret +metadata: + creationTimestamp: null + name: ssr-backend-secret + namespace: public-services +spec: + encryptedData: + MONGO_URI: AgBDDZhuphvpFZJZq31CA8OmiTr1J8p5Fy/rcr/zvm2nl64GnpbmVCuYgUiH6PwZPCUa9zcyUeZJO9/Xqe9PbJ8hA82j0Pb+Pcl1Rk3+B6jkDaEzcJKDmXS/zx8Q+JPWFOGVpRNy0HCKxm8azy88A9iyiKseIFsMWWrkJMkEObokRBCB4joD9Mh+aOsE2vaUkoE49ASxwVXU9MnL/34eksqGD6D5/BGpVZGftvY/x5eOuhULtK7z3tcd/orc//21AXUSAlVgWcekstEfZWQovk7Rwl67pgpHYf+KuegY4i+0ybge1qEngjvwt76yObTqfmhrdVQNfrV21FpTfoBeZS6ZoHdli6DBanPZgXJdKU2Ttr3C5EJ8c0Gir20J3wRs11SQ1gaKu6bxL4EH4kAtgdVoD5t6MSqvDzkfovAcJUHfXLA2HPhs1CEcu7Y6Kv/v+aGWSlo9jPVQg8JJ7IPF/+DDbF4JgEnwr34e7M5Z/CKVhwm7mK8Nr1yzgEhkucjZ6fcEVmt91fjx1usxDvtN+mllibc7HS2a/ObMDx3MtfHxXhTpt0wXyNyhXtnKNvKbICR5LGZfosF3viNfuRcEFTGvC2Ak9hlhVrznp0FRUiQJSNWsBQKZUKG5Yd5ckQwJUY8B/OLAjg0Keo26LIkciZ0jZD3JtK+bxU5LntdfSCwuO9+xHgaMgCxY+7plPQOgXL9BAOk9Zerc/6xQXJ7y4Q1PogrNaiPn6xCr368utFH2bA4zOAZCwrngnZtmT4pB6r6C/425JoZiQQw6qVQklpC7UhWpV1SbMvdGrlYgBW9TkT5rJPNIE3Bp3kA= + template: + metadata: + creationTimestamp: null + name: ssr-backend-secret + namespace: public-services + type: Opaque diff --git a/.gitea/kubernetes/backend/strip-api-prefix-middleware.yaml b/.gitea/kubernetes/backend/strip-api-prefix-middleware.yaml index b1b2677..19f113b 100644 --- a/.gitea/kubernetes/backend/strip-api-prefix-middleware.yaml +++ b/.gitea/kubernetes/backend/strip-api-prefix-middleware.yaml @@ -7,4 +7,4 @@ metadata: spec: stripPrefix: prefixes: - - "/api-test" + - "/api" diff --git a/.gitea/kubernetes/website/deployment.yaml b/.gitea/kubernetes/website/deployment.yaml index 7a4e15e..46f34de 100644 --- a/.gitea/kubernetes/website/deployment.yaml +++ b/.gitea/kubernetes/website/deployment.yaml @@ -27,28 +27,8 @@ spec: cpu: 1000m # 1 vCPU memory: 256Mi env: - - name: MONGO_URI - valueFrom: - secretKeyRef: - name: ssr-secret - key: MONGO_URI - name: NEXT_PUBLIC_SITE_URL valueFrom: secretKeyRef: name: ssr-secret key: NEXT_PUBLIC_SITE_URL - - name: NEXT_PUBLIC_TRIGGER_PUBLIC_API_KEY - valueFrom: - secretKeyRef: - name: ssr-secret - key: NEXT_PUBLIC_TRIGGER_PUBLIC_API_KEY - - name: TRIGGER_API_KEY - valueFrom: - secretKeyRef: - name: ssr-secret - key: TRIGGER_API_KEY - - name: TRIGGER_API_URL - valueFrom: - secretKeyRef: - name: ssr-secret - key: TRIGGER_API_URL diff --git a/.gitea/kubernetes/website/sealed-secrets.yaml b/.gitea/kubernetes/website/sealed-secrets.yaml index f2a0a7e..96b446c 100644 --- a/.gitea/kubernetes/website/sealed-secrets.yaml +++ b/.gitea/kubernetes/website/sealed-secrets.yaml @@ -7,11 +7,7 @@ metadata: namespace: public-services spec: encryptedData: - MONGO_URI: AgBDDZhuphvpFZJZq31CA8OmiTr1J8p5Fy/rcr/zvm2nl64GnpbmVCuYgUiH6PwZPCUa9zcyUeZJO9/Xqe9PbJ8hA82j0Pb+Pcl1Rk3+B6jkDaEzcJKDmXS/zx8Q+JPWFOGVpRNy0HCKxm8azy88A9iyiKseIFsMWWrkJMkEObokRBCB4joD9Mh+aOsE2vaUkoE49ASxwVXU9MnL/34eksqGD6D5/BGpVZGftvY/x5eOuhULtK7z3tcd/orc//21AXUSAlVgWcekstEfZWQovk7Rwl67pgpHYf+KuegY4i+0ybge1qEngjvwt76yObTqfmhrdVQNfrV21FpTfoBeZS6ZoHdli6DBanPZgXJdKU2Ttr3C5EJ8c0Gir20J3wRs11SQ1gaKu6bxL4EH4kAtgdVoD5t6MSqvDzkfovAcJUHfXLA2HPhs1CEcu7Y6Kv/v+aGWSlo9jPVQg8JJ7IPF/+DDbF4JgEnwr34e7M5Z/CKVhwm7mK8Nr1yzgEhkucjZ6fcEVmt91fjx1usxDvtN+mllibc7HS2a/ObMDx3MtfHxXhTpt0wXyNyhXtnKNvKbICR5LGZfosF3viNfuRcEFTGvC2Ak9hlhVrznp0FRUiQJSNWsBQKZUKG5Yd5ckQwJUY8B/OLAjg0Keo26LIkciZ0jZD3JtK+bxU5LntdfSCwuO9+xHgaMgCxY+7plPQOgXL9BAOk9Zerc/6xQXJ7y4Q1PogrNaiPn6xCr368utFH2bA4zOAZCwrngnZtmT4pB6r6C/425JoZiQQw6qVQklpC7UhWpV1SbMvdGrlYgBW9TkT5rJPNIE3Bp3kA= NEXT_PUBLIC_SITE_URL: AgCpMUZ2MFY8mHgQ3fizTzcBImnwFmWzccRCtMAThI0cAIOcDe15Drk2a5a4UjcYgl1F+JrHB3b3IPbflr1E4dNAANKRgiGW+gyI2S7J/oDpb+ANCv/0RJIlfQh9Pcb/E4noKVOoUfe4dg5asq1kQjOob4uOn6MfQXoC5WfgK8u8q0T5tEPcuGxXt2Q1OnyAAWm/0Z7JSLfgQN2sKaAbRbWqKfwfsc4LgjxY98m/+BkXN7x6R7BJmXXMd0cb5ctdgM1ZpU+gYhhwyO0xsxYWURcJb9EsrNZR6OY4DbwXw2tpoagFxA20u5J2ZUhUeVRg2x2R5AdkL7OBIT73Xbh3WxIYVAqGDhs90aRrmlCdr61eBLCLtytC33LJ/6Odq2Pa9DLaKqRlqRX/IWk7+cgHOKfSd8/k5R1roA3A96ShFby9RdXGudGLA2G4dvLtrruLCYVRfxMJB2k3UYtGZB21o+3SAV0jx/83eoYzoBGHM6K8ySCpL1uDCo8ATL2iYJcacgYZGKaGxBumzEjAMBqTLBSUl0Jhx3mr59p6mrYKFtbewa9rJUOkNniYvdCeokLyVntxUMx60Jtrtg05G3vSFaP34Gp6Oq6J0jSzvYi/A3/iSe+cNB1fpNJvJVLRFmJ6f7qyMMoSujIoql5SfIhx/tyUHueiOFQ5KXKTeNhbu6byakY1ZHa2o03+Mooca2ATwUnlNNi73sKluFKhnRysANIiVoRZLDQniLwV - NEXT_PUBLIC_TRIGGER_PUBLIC_API_KEY: AgCZwhhUNhSMwuR1pCP5qDY9fD99u78PFq89ej141pc/L/y/UCydLvftFKT62bXzIFhoq77dlU3yFx2FqbApdiDv3sDltZkIQh/afYwySPw3bXxoQoHcAix5qGhWrpDkPFDOi+sJkkPnnZC1OBncrqz8xAwfYAhwOscW9mjugRMJPynqSlnVHS1RdYm6z7eSJpZEMEHIT4tptPnzP+icRwbolgKL66JXFXvuS6SnTZ+ZOtub39L+wpWE9dQ83E5YqtWl3hci2G+rK9KBk89zuBM7Ho+MTpcdcaes64ApMqaUnFPelqJKSk6PK7mEX9DZhCUqNyCu897ktfHKulVZQ5Wy2+pVHXx9e1IBI7YqNph64CbX6N0V6ABfNlO2sS+zFG3dGuEGj/lI9hfSxqQauYOWXR7r8zM86WvNuxWuQFQbO4B1TDd8oofhZ+wwcUfJ0/pZIqyxcINB13opF107wa4MlfoCI6sgB4/adq/bbMP/JO10/GBiuJRhE63NhVJEZovJoRNV2+wBRNSVRfZpEQ9AXSACm1BtqOxhYhAmDnJt6ThF6VDWB2ZoDZfWul/kPUTUiOulGHmsRdn/bzTS8GjhY93G1/FpNmhNSOC8YbO3FDw8vXg2Vy6jpdKOhy08H9R/9UqbiHxnXPyBGyoizbnjP0sDx4jYYXtix03ZPFf6Dxz6iwwy5BbHpk9Ik+3l2iKI7IcxOOS9P8ljlsB0cCivpTax1iuDZ4hlJ7zm - TRIGGER_API_KEY: AgARH8DdSu8INQ2OW6I4s2W+HZqHGZHn0i54l02Ui48Oph9koB6pfTvAkYspQ6LI2zh/R/uiAeOHorybTMZ9X0EEwk5GxTuXBUn4f5Ifpd2QkoHeDVWP6MA951PVanfPuXLklwKJm2O70oFKIVE61v52yZbk0L3wAOiYdRTj0igrSEDkmmc9iHorGdbDCI3CkZHpOMMl37zdIwCvbpHaCnSBpKEuQ0PmvRtAw9ydM3FhVpTxNVh3KhTgvGBBYwrGXOZuKOayLGvQ16pYmTSPoN6DNRFSLjmE/BOjwKnYfZU0C0qkpGPlNLSUteuLLvHtzlS8IOSboOspreQJMVaSRpg+Qp1/cV0XGEhmU/CWVTYqkNx5QtfgaxWllrKrQxNW0WMDJmnQI83scsAiweSFUffsfiX8BCMjHkD2nvlXCz6vzUcJ7Zn0bDPoHcv/uG7efZbsJXLie1PxQiGwFYpuyr0b7+A+RVgx0G/WNwKJIUjFC7acI7jY4dGE04zKe1STYhMhoc1gjKGhXe0BG73LAX/O5/x6W4iYUyc4n0HL7gLwlbpfR3zLkvuiiAtzFeKGRr+SF24mj95pfw+MPFoKEi9htLdPgHxTYomfQ+1I8R7Iya0sHtyW2fI/1e5XzJOMHub/tYh5y9h0UqE5n7ByapRMyj0mOrKXXPUoT4btQDz0U6aNRX+MrlwMsuXYjSfUCuXmy30RKQImmT+9vaukIq1CX7WJ2LQ8fHaYACnp - TRIGGER_API_URL: AgAOwyGxQEScm5T3Hh1armqqcEcMEo0v5Mwf9JjEf3G+3svlDDPGHlyHdQolcC2YlkX7DhsenEp6rokh1grwyVoruyUc6OmRdRR70+PV5qMgSC3HY6lZ5f2gcGfA0uh9A5sm4qkOw4rliRddpJqKOqDz28zcrcu5RmusPxric+KF6Hcdy+ugqmq0KZl9VU2+D4z3QWkdokHk3WahdLneS4a3bHYC/NIpKyI5SveK6QAaQlU3NXrqKcof6VzDQG20bnCKGo+Y935LgzEIEmWKw2C9lwCV+/RUIjeaK2qzZpeMiZue9zgoq1dyNNjrar9B6zb+rSxcgnbqBolXUAVk1If3+egVNEaB9SjU22n+WoTA6HK1MOSwsaMtf1Tug/8nSQfFHdw1nZzBVtiVaFMtzmg0aKyrUpAYyz4XTn6xn9EhEKgcPSaWINf4zVcmceLOYenOP/y7S3cVx9KHBjUNGf/eDJVmXSiOzeguIJBfdEOla/lqv7Zx2/wvfHeEdurn5ENTkG2aQAekIvWiJ1HzPwrKKR6WcBpNTgjoDRMNxVoMcZ3QB9iJlp3AoLfJW72B2soTVeIikcNlT0Q0S91hiqvEcE+WuE5bDSttzhnb9nvEJXz6gC6AykCKH1VLIJJuiMI6R7V9z8fo7pFVXbQsM10VUph/9vxhib2XZ1c/25YMfj5vaI1+N7UiVDFlEfE2YJQMwd2vj+wTa3wHJz+2KXc/9rhBoaIpznN4LmKR6g== template: metadata: creationTimestamp: null diff --git a/.gitea/workflows/deploy-backend.yml b/.gitea/workflows/deploy-backend.yml index e62ffdb..14548b8 100644 --- a/.gitea/workflows/deploy-backend.yml +++ b/.gitea/workflows/deploy-backend.yml @@ -55,6 +55,7 @@ jobs: action: deploy namespace: public-services manifests: | + .gitea/kubernetes/backend/sealed-secrets.yaml .gitea/kubernetes/backend/deployment.yaml .gitea/kubernetes/backend/service.yaml .gitea/kubernetes/backend/strip-api-prefix-middleware.yaml diff --git a/.gitea/workflows/deploy-website.yml b/.gitea/workflows/deploy-website.yml index f068b71..54f7b35 100644 --- a/.gitea/workflows/deploy-website.yml +++ b/.gitea/workflows/deploy-website.yml @@ -1,15 +1,15 @@ name: "Deploy Website" -#on: -# workflow_dispatch: -# push: -# branches: -# - master -# paths: -# - projects/website/** -# - projects/common/** -# - .gitea/kubernetes/website/** -# - .gitea/workflows/deploy-website.yml +on: + workflow_dispatch: + push: + branches: + - master + paths: + - projects/website/** + - projects/common/** + - .gitea/kubernetes/website/** + - .gitea/workflows/deploy-website.yml jobs: deploy: diff --git a/bun.lockb b/bun.lockb index 82fce6fb7b9b27fdd3e2067eb46c1dcc0ee05f67..ff1239afc9deb16de271a9f60567aa9cd49eea36 100644 GIT binary patch delta 65681 zcmeFad7O>)|Nno^8HaO*u`gracT)&w#+YO5V;E~>NeqS=jA3Rhqox^KF`9}iT@)cI zigwu&D#b{WlqIE9mMGd#;d_5v*ERC))Aw`xd_TW`et&e`<}r`g^YPlBujP83%bd%~ z-^%ZqS@zM}n*8)?>)!5t4|aQGYpbuEP{qZ(c7{H%8n6OZ zhO8Em6G`SWs6grF!X@G4#EghB6B5VHz*a@+)5ocOuFQ-HDI?(tX`>S}l3lJ*$%zxj zV;AO|mNYUWCnYnr=%UD4IhiHQ@_QYf-9wi=q1m^wk_KY?8u`yp86-Cx|5V+GQaGSVhYO>%9_(j!MFb)3B9rJFJG3s$j>rbkZ00uokxRr6#41O={_K6;@of zqTS;$i5Z!bX=r-Vlnk}`5V}10SPT5CNpljW6S*EI!>Hu6@hPJd)5l~aO--7ZnXx|H zZa|yLMq5vfoJXrT9ZpG4%%q{IGvo%BE3K;C@yz6;iK^!=#~umGaXIAEoVC0``j*vQ zE^5rJ18Zb{gy~A|Hx5rrP8r3_aiwG=O;3WS*07qHliQtPQh}2aGm|66CuLP~GBmE` zI3i_Y%A~}MjFRYb%$MluU#r@7KfI2Kn&GYUDIvR5hTLV6`hfDRERL9W05hVpcS=`38pK zSv%>Ow~fm%8r$tWiY`Ze0IS^-lcp=X>*b~{7cIzr+~I6k)sJ?#C(KahKavKdr3i2_@-aJlNBmxpV?zc#m*$Y(Hvk-Np=WiUR?&4sJMqaBWi)u5Iz z73WrkE3xcyy#!R@kGI==3}$xc?soVUm|35@+~N5SPlq*y-U!&9NyRRXospC}H7T84 zCT&6*fpWH)F5toW~C zmD{3=Rc=o1Vgf4Y3x_`?fm)i?)#k);Q>mKq>Sm8&2&^Ibh4@nNl=PGcZS|9;Ow1aU zHacl6x;oInDX)BYyPV9V^b8HnMr_5`j8{jP1LX;*0fmbvaa3CRL@HcCLd}iL#7T)G zCnUM{J3V;^mIqQ(Qj;P^k956(EmtpvHTmqlis_B5j*LyP)87N*q?`66;!9}`j37`N z?ggu59i5D+z3m2!fK@?HSUqn_yjDvYShIR=U*}E)%eB?|*%c1KRtKKOR{j8XT{s+O z=gK|Zkw&V*uln09?{}xm#gfmxN`&Ub69eq+V>YY`Cpdf?tmPFCYgry6y(;JjtAg7d zKGxLkQ2#;pGR;U%N#*9?iWzJ-^w?nhuZl(|jhr$Tm!~q0x*C%}J-s@__Dof5O`amK z8WKx^x^tAFBCY?YI@k?Z=6gH2=tHpLbBE!71*RcrY`!6bT$VH3 z?s>5hE>}bB!VG_+Ypxtlw0p1vR{G~1`!UDPhE;L0qYs4T={Q&&nUtP3miv81Fb6@C z=@m{2;>)lSW~R4lBTqySUk3X?k{y4^X+YsdGcF@-Vr$ZkO`4dIHF1>7hZ~ju&oOq# z@{?_QJX{C853D=F1>&`Oa&!|+oRH!A!VwO_a?waw75_HQ9)h^>wucJOPusCIM1|+5 z!t+<*t?2LPwhYSCBp+v=o?P!>)LBHbRc1`bb(n2!Ej;Yd5$Ow)TTxv+T)t$l+Jv+UWiVoujYoLEA+S!WxQ6 zVW*Ta?zE5cevK8H!4GBGpKwa_Ve1}qO2ZajtiMta(W2~3Hb z9|D^Aagh=RefI1;`Hk$6vOQF|ioUF6Pxi7atPQPh?sgKYWzWKLQQ^ii z8(R&Sx6%kLU&E8U%4kylww!mLwr5C&6FYddUER2h2@V<)Div!@nre}#)QDPvN468i1W+UZ79kJjcu7&qkP#uHG%g?IP%*qXAf zVQt_|9Zp$mH-AFX)QQYo*Klmb_jGt5tX*Mr5<5{!hO4%dFC4b?v{9MqDHD^DGNxpv zusY#~pLUo}|yystak)o-{+8IJ6-EBFRmJv;!bDuHVlt-uxSBHzk>Y*D}OY*=a8gx%xYzpxwh ze>>WKPkPOzC12W;`2wt-j^s#7v99rn?8K=q*Wu%Kc}Wwpcv6?>a&`U69>WM&Jsy?D zVDyz4yE~ z(#L+hXvHH&?YcE%uT5~jJgH9SW9!peyguQFQBR#8J!HU%b~`&)uk%&io&GvohPam< z?|ON~q(3LTb1h-$^x@$R=OlizvSptEhtAiVkyd)J5nDaHPv@W9KYjaF=FTQhy!hyv z858d9^3JGH`y2k8ZhW4+HEhuFg3#N_=frjIf8XL}<-B>#+b!?9;>7b$SK4-E=hstX zmPhsat<^X@X{Vv~RCZR9qbmmIvu^X4Z%`o5g>NUt^*yIy`Q`Pht# z?MGGpdFac&K}jidCe(euc$F8U?_WB)cV1lWURjmjzij^5{NAk8ZFhY8?1OK98U65V zSF5k86uh_Q^>+_%{OpmqnEanp-`>1^MX6JZ8`Noj*NH>-MRq>7utoZ7wHoDmd{uww z+5MyXHTD@Ro4o9EXGIMvS!;U-qfgT_r3>r1GUfc?Ev=T!e=V{Z=_g)|x{@%iUsYrC zZPQ?pVXs?JCvp((+L5>b75w89u1*fiG^Gy{+EhIwva`>nnG8w1cjw^8o>XMk@gLt$DmnCn)Q2yx?)_8OwaxB2*1znz&jyS*f8+N@ z6Mwtwg|Q6}4n1|({%(<{UOsrLXZGO1)kpkdRGT#5z@XE2r5ihHmodWYH)>Sk{*a8@ zYi`)w=ItHTju-oV)`soFFJyOF@l3PhQ6rPib$I^Ml2H@OR_M{)Xw+(uv9p!Wua9eO zna&>LV4Hw{9acxI5Ti_ofaiOckvAdeZ|mj(Iila_+b-Z=hII#48Drt7fVY^(<%&1L z+C_W&66$J%b%^$xggTI-f^je*(ubkUMT~>70bl13mun!FIk~5&xmEuYC_xmTQ6@Uz zKZ4Z`tEg2~jUu+?wX7jnk;Kuq$bf$lR!b|N8nquwy(ngtQr2siS;Sb_HsBwI)sZ-l zO7X1o8U;Oq{%;YRBYKU4?E>BkMaga?1f%`E33axLEkm&emZ~gb6}t~hO?4Y(E(ZMm zVs;5sc`@LP!fI*ccZx1Eo=_X*!IkW&t_ntnRt>GLu2LGSw1uq${zF)*iTshb8@a9G zJl#T#f>uG#)KDX|b4UJ&UHGvWh5e+l+9c`C%95uUCkje-DGmNY^mgZ@p#sYyjG z5C4JH1*?=%CJ^wXlrjn;gTBu=I*mazUwODT^uL9mHZr5= z<_#=)->v$59q@=+bThj@8$|=b=zJ3C!{~#7zas;_YglT|&CdEdl(k2|QWj0QZ)j}7`)R$qYB* zp{?OYUWcInCk`NLl2xC#8#nQIBfo95XJKV4)!xcRUdN!PQ5B;AT2sXc?G*I7YArjim|X3D21~QtuYJt_1D0KGU-EUVt)=9D zpmyOxIM^-VIZ@k)?;iBmtYeElYwo^#(^5CCV>vSet8HD|Vy$3J#fqgqYoPt_Vrht6 z);jXlv%Ams84UQlIu=uw{dNwP+G#uZt$Idiub`)IeIp)PUEj#-74-WXFv29U7Ob~F zR##(xn`qCP21b1Ep#Lz!ZA7!aI^w~Gw#Bx_f@smu2<;Q}OlfGu_X##SPn?E`rSKou z5pbSIb`RePa4U53W~lugF4r(iTSjQ8LjKDNS%bqg^!96|wj>OQ_C8POPAhbsP(Lfwr?pz3 z*p)X!7YX&Uv>;2=4n0Jur==aa8LAg3)Fu(?s#yPSLUJS%gB2OVx{<{;J2KK8X@m|9 z`e&hOC0e&;&(26A53yua;oQ}19foC(24mxSGRla*JLvg3%E*Iiv^5Iu4*G{Nf3+-_ z`b@xsSat@MnzvLmLu+}pd$dt7Ea=ZgpvT%YaL7)qSR*VD?RFX6N458)wlnfZ1^r7Y zPK&}mgdA#TgpLk+B4UhqXhDpT2YnV}6pRjft5H=OBfn#`w;!RF#{PEE{yEaDUX^Jd z@NUIwuJeii9HBwzOsq};f5-NPw;dfj(y=-c$DY(a;Ca2h5uY3MRE;(Aa_{#~jJ?^v z%z*!SENwv*jWXi`o*!b3(5XSs?H!D~MJ(8kM!}+>zf@;?x8Z&g8}JXnQfIguW(E9@ zVDY1!fr<`wHVTZOzYBL+-Sw=yn13Y}O;uaiSk5{Y3!3esA-fn2&|c6s;F;Ui2wfcX z?CxsBFAn-!bYn%_%$JX)GOdcdC$PH8PyV`b_5iTq;GMg$B8{+^XwM^YM&9G>CJ6W< zx2kcF%4>DEtD`B=0nfPZM!XsHyx-l(gBr&h1!mBn#MY$N>h>P#jyK|$1U;vT$_{#} z^f2<41pOm>*yCXzFqdH2?pT-^@O<3Eh+i7?lH&^zxutdVM){~RGr z8mrg-8VNVI+Qdj7h9)03c}@=)D%(Exopz#_m(2gVz3f4uov8tT3Ko51&K|@IuVVGI zR_=8|9gO|6qy4jc7v9};=R4WkC|DNs)a_%0F6VyS$H>bL`U~(c>FN5x?0{!UUn6gM z(0imW18n3kkM=j{cXJ*VL>9qd0FYlIq!wE@dhhpGBcywK^tU!DQmiIqyOeUh*Rj~5 zNHOD1mus9=#y5m?OR#G8ZyaC`wQYF^+Pl1MCH#lA7>f#>e5bMQxoLH|%jIG@64$p= zz>|BIk+&-7KZ!U3(Pi}Q6YxwPWEAuZ`d?%fk<}Kx{RV3ZazpaZBc#%c8GTa%{-3b4 z+-L_2EPhDgR@0YxbsttU;yl_iJZpy-q1i#Ne<%$%!rDjs`x1(=vg)A!uyib|4cy`?odHsF5(3&%LtZ8#{c(DE!FVT3*#^!zr$ zh<`TdJ(uWm^)M2ijrO)4NeNbH79m^Pe^aY6iiTRTDTM6U*9mp8w5x3r9I|ZwqK6CiDDMTB**5?#|67v}=#On3{tmatUUKxnFSeiPdjF0qTD4TtJt}w=k ze?I7MIM!Zm9Beob4#jFo5^7{K%g3@uW?@diUx1~0tnI;DF&U2;3C~CSR}lJ3{~l|M zbZ_4j>;AW)T@eDYR?@SCw4D|=7G?#!wZ}QScPgQFR;E`8b+$r9$1`G9uD*oqh0ea@ zUx4K_ksbCRmNq`t7bm~k6YTw#^Lwv=F9}Pv{dJ;TjuJ#+uWl3Y9G_t1y%h9UPqp1* z9jUy7vFvKs5K=`XZXNJ`iWRgPR(_%uv3mFdA@1;mLYNXmtk5t*qZRUBBBW;Hdp=q; zon-G1+~v4aCSu)VrQbn_J2+){CtI6F+h~6eLTb1@8=t^(7GSS{{~}f!yHuRfF5T`B z`#aVQ$6_q8He;z2dzM_pQa)zM)JR{3osHWC>oX0DlSgi8qfD28e+^c1EY8yS@JlD< zU!UNrXWHH36FgJoE-Y2TCfuHf1z2`o9S1(Z>S3+aR#U9i`*gH-B_Te*5IRSwn_@jJ zrW)~^f}Xikjl4}k?~bX3>pOOu4sQE{(VoYr8S$Hg-j5L4TLliAUbr2+LWr%Fjo~sO ziYC+{%jJr)ivI9EqhL$W6U;V3w+6j0X1iPyjs07q{nhU0&m3tsr-Q73XWsor0nrx` z#~Ay!Mtdgb81dVJo?SUc-nO8(L#_^EwAud@Ar_IcaYGK7X@tHR^rTqiJup+b!?v>c zXF26~pR__~o>H@o`0YV&!fYqX{~93{y7oC9y;PiIguWH@7;}vHw}Rd;=UDf`x1v1> zbBzLod2==8!rqGZo*{IP5%y-Zx9bB6(JIfH2aJLpL4TzO?bXVej}y6Bnv(R!qWiBUVw@vH$ELObTQa*s*v8d3(hu-h7 zdRZ-PWfX>H5o&8`ZxZ7CL5aS{SOS#6jggzu@W+gTJv>iA)R~Mq#+mWjV@CYmpttQ} z>uyQkXAxpV2)%1*JjL=hcwC<<_P^WCN8nDo=``b4tUgxKE~YjwRXLkboK>Y({yxWI zT5xD;vgBqb=wK#RXHv5L^$U16W5pN=e8~BgP_*4X8r^)UQSd>~bLt5rbbrv_ZyA5G zYSqvB_rHrZ;HFi1xqV*X?8bTF0W3M0()l?4$#Nt9!=OL*DVM7+qIJLXFT~RRX?+s* z?0d=xJrML)TVZe3EE{SVgw>rS>^|KB{#97Lu^3M#@~_2nOCIt9GVpQaFF zf9q)PXhO}6gp_FiV+xU+#&RY-fTa~_-?hV5b1SrpI@lxNpMYgMM>A~?mYQcjUAd0s zR7zZ%HFiGxto(>$*>{5jSRKh{H@3<%h2HGjGt!6AiYN}meCnHrr6R+OgR=vkozEBr zhlBn6n&TVDzK7_?Ri#~l!$eAR3WT^O@k(bG1u;=VXaWbUz$FbB=4kUE# z6D)b&dRF2oz1ArBEa=Z#TR17TbM3;CpRCU^{!3VP2BwWSVjY%sVoM>U*+K6X1^lbA zRv8}TVYU)=L{_x{>ta#57po@Z}37A0|nIFB`sI3~l;fam2GjQHb0 z|2OOHXQR0J?SLn^!6-N$^iAJjx1g|Ge-VU8BfmZOHbQ(nZOx75#hd5ib^+gVENw)8 zZKCH<>~3g_Yw!{)(CXfEgyc0=X6MKv7=u)tulLLL9(1z+?^=|O#{SOH{_BM7EC+Qm zc%`slEbmmTKF0nY(f;=c$-DG+L}U>q()RA{^r|*0JrZ9?$euP@MTf9DTgfTLQ|C3K z;AGIVcB2vcRnY5sL(@RLj3cBO#Gb{?EcXqgfatdn@3ih6%{JMeRjpPp`rKax+s^m{ zp?-F2+C&!FY_*0*FB|L-?K(|}j|tRq`Djt!U>XVDHbGMKN3L zA!9ROJxRhRvtC!B32vgUql*gzqt+f;lPTeRLnL~oyB4#*hr(^vK`Ha7#r~1`<4-ZCg|OX z$ONZBe-g6yat&vn9feCo))Fjj7xvOVf~8r-l#LB|YwXm7kjEwva#G=f^;kT(r{Xh& z?2`L-jdbra3eE++i*{K$POL#UTO9uG%~EmpXe{m9+y*((zlPNki#x+t0nfMZ8U+`4XusQ@U^Js3 z(uZLWA-k9N8?0WI$6CH;@AwQ8`_3$^k#>!X0-kT)Gva>?dKZH;Pyqje#$2g)l&Y+oWF{ed zMfLrWlN1)4Ia_h*V>j2tqCiOUF$V5hOvuyl0{9fX?^?G1yW{rM_gP2x8lSsdEvSQk zj^%*d8%ut+KjO{C(#pXf{Q~}tSZ%QQw2w9YguNQ<pqoZZ80K6SeQLnX2JV{7Po$}ZvPD!^dK3qq9 zV|S&5ad2cHECVZ&IJb*G{oqX5bWxt)ho(j9v<)lPvNRgazqS3(J)1Q!605Bhr>V9K zOTFNuIdLD|8W+~^JEs)t;WK!ksrAptGf_2~_F?Wd$KnLiInw=|5jv}V>C;YA$fhRN zzWFcDqlwWyp2IFj?Z>cGko_FOd&XW~_9vtMSdNnd0qS!FGj5#%A&b9vnfpBMn2@6ciifzPa=cU`$Q4B$E(Yro z3nd+{0PFfE4nYsMV*is>Ze>Tmm6g64f9~c|pe9hlS`OESb^Q}7T^%d-KUsQRN57Rl zX!U{O8-S*uFVNLje{QCTexM>q2FfrF=(?4QU{3^U@MNIt|Akd<22eTEfXbOso?p6V z*krNWyimklla{$$*^c~AtajfI#8zU5RnJVIODub~3@)+K&vAIJ!wUkH?L5zuuj`>>y|RGXFVNyiq;UZyUopr?T5;4?titt=0& z11!jXBy5jb+YTxNDrcf%aTZ*}BtlDWhx zZ@UaGvFdvVsNDB}^1TmqiKQ3F;QH@a={U7m7o1~N#8Je${$E%P;CNzPVm0_<+q78r zCqU`AR%A<}RJ1byGw+{y(yc@bXJs)Zt$ zs9S%wXa`(wbGgr5$r_!KPP$uJohXeiF6-nk=cK!pC6#w{vC>y`>|0r*QW;%QRh)P@ z#}cYK!mX@@UlUy!>%c0wo|Eo>!b)1-NiSCZh7LD&^u})1jVfs72x0{z99vu#yFFY9 z9_Z*|6*$PT#hSnOz>;{f(7xf}@L- zZxXEf(;fZ4V~@EZ)LqH^B-9;l?NU>mJYtnG&9TJ_PIv5ES?Ojtx>#+vA6DCDI{K|F zX%@falX*@&Y>)PQM-VHR=h*+ms`w$|Rna3(IbONu?pVm*tfEzZTwQhw!&&z{3-#Tb@ab;&fkUqiLIV|Pl4hKPQkab z^dHd`b&+4C;2Tc5zj4lgM94KB<^I30JmYub#Y$fSR@q^&qDt{g9w{foVWq3+*kYxx zWZ8_r0usUQ_!Vigo{IMU%LC;nDeLt`9W>^5V<*vmB~x?(Ci9+o2#9PXoJ*!q)NU1F`yyBvLx zql@ME;jqdXiu16eR=;6Gs1dlnwN!r`Z2U1I4^!%F{**j!$U z_Dg=%iGBl?-?zbP_nVHs6IM38Aw}umhxu{sb?p7H(tqgKA3OZ1qkjgg-zOaV3s~iS z<-wLt3OeT$cmb9pe{stB)#2Y@eq2}hRT}n@Ne%XgP`?T&!7n8! z3H#wnu=FbYl1FMfwqD_)boCVJu;LqItB1Ec@hu#_!_lK)eMad9^W%zlI3Y(CR|2ep zdpi;R9Q#fuUaS@mfK}lTSW(0Hr5>finu=54FnAWM^5($0#L71xRyldF(&sF41jFIS zU|nKuYb)Uf@OzGaE31M%jxJVkpJR(v(Fd^7edy?acXwu@v|MCNFFh8y}j{O|0a@RTf21Rm-6@10vjn@8)u+b5=D3(jC7H@TUo1=@R zzX_{?w_r777tD`qx1+!B=mn1cp<^F}mH%VM&N=J^jyQbW;V)oq5Z}S7=nTw{>pZ_y zzy*hYfYpFYj{OtNkLx$bzUSHz z>FdI3V0~DZSaw5L4QUE1syV+@ehY_NS~}w&AfOCUur6^KINs5F!pfN7#EVsMUs!wk za9B~J`K6oBcvw5ra9zxZhzt@sgeEWccq`>1L8nAD9eS6)kqy zgtgKSiGKsmQA^(@pfTL-@E%wh_B!?lFh8z?{L$M`EUBEMi&b$&hbud}SRJbB*ka|cmP0^eRNoOAI0^oV zm7$@NzPXd`R#toqNB<{Qd`l-@tcK>ab_B5!v~lcPS$Y6nJ&1DB5wu(@_bOi5#2R;9 zVnHiJ{7+A0k#npFwd8;CXx8dMP4cSZS`OESb^Q}7T^%d7kQsjoim2;E+{)^4eJ8#F z+!XW$y2Q%Y5B#s6%=RJkWT5N6V>RTzJesv~{rhD0-zT%qMEIvCv|3vKKAF`z_3M|z z3jX_K7B6UDY3w-W-zT#;tc>R!5J2 zpUnRIWcJ@DvwCu?L)O1fX8(OM`|p$4f1k|$`(*atC$qOcq1CNjy<%8oRKAO>c!P6W2c@I^{Y4~!*@5PM+Mo>g$lS$NpH||9X!#xAeL2uY(ya{$(fZ(7^Vu zha|sPXHLCA9e2F3YE;n;tN&R2;o1B<>hJrxnLDv`zmlJ)|5Ev_p)qquo}T>pPWP*? zFMa-xRZmqov?K2N>JQ#CFSK`un;XWvdn8m_L_J^SXqF`)A4*akN$jUAfy$ilRE zb9pS~daHSQxT~9A-H#szb#Rw3|D5RQU~UMwi@R%@TLP%Qj<~COB<`whrbHramatz! zU9)l&LUJdB>?nl#=3WWmoe?71A~ZC!+9K?ca6&?3vvD-S^ezbb(Fje=qY@f*MTlvK z5Mj=5hj2u~1qrvCQ85Vfx*;r&L1Bf_Ab2&o+r+M8P> z_!1DRcS7i3rgTEsEMdQdPG;rK2+6$=vO6PmG51Oc?~M@A1)-an)dgXXgcB0Fn~l37 zOz(q`-xZ;Uc~nB9z6deh5E9J!-4KpQxFDgo85M^xuOGtlIE23DSp;`KvqN{Nzqu4L z&-5qyT6dxcnBC(MmfVT3As*o_^Qwfn0SLpW(LLB)C-uqz{8zFk{u^oz?uoE=ASt%@ zB*ie(pMWsvE`-zsgyH5E3BExH)q5c%nkl^yHcQwqVU$_9H$w7YgzVl3N#?S1B6QMOroD0IJ>CCV`migL}ycSAGHnW9oP&H17==2xO;%%~LTS#zQ2IrFS& zt=VB5w9Z^AdfvPwdco{I9tSl_$3fl+IB0{}cLKr@2^%H6WV%xk=4Buxr6Rmyz9Jzq z6QROHgxAc(i3n#T?3S?6ER%+?WD3HRG=xp&E(vi{5$aDu*kVqegm6W|VF}yJb<+^m zPD2Qrjy05;wHHj<&2c1ttf|G=BrYcp{iV5d z3SINfL5Seq>iNoL&UlL?o2A6eM>*v(o9sYIo{N&d1La$nc}z<914^?K5Sjq;<*{7K5Z`6vV4LAiv( z-a(1XL-D?g@-q&57v+qUjZ%KaVY^Y5EI>)xjdB@>Nr`(1rNVnCS8>>TC|9KHmhuM< zdmm+OKFa{n^z?aT8c32076Z3-2nvO z69{1+A=EYpe}u4E!gdLDP5(iJ|K)ZCmYYGED~wKQ9N3f*DOhs-l8Dd@td z6x7;``V3*oDum^qAq32`65^glNH~HJWiCB}a7Ds33DIWvqX=tPBWyT|5My4IFlY_J zuww}A&2`79EY@@%hdP*pMIFsoM4e3k=TK)eQPjoUBI;_EIRSMuQ$%s*E>U;0@)uCN zIa$=h+$-v7*8UPoFtbFx%!8udX5*7IVBYgIApax{=xZL85cvW^%vT8g&G}y;oRM%r z!T>YsYlJ215te_AaF=;jLfi&~gi{EE&84Rhu1L5hVW`>t8-%qlB5e2uVVHRp!F`X} z_giSVxlS~~bbkjWnuA3n%~v4LD7P7U+C9)S+HH;$C%Mh7;xTTs>=}5h+Z-oOcAIaD zQ`}~iv+y{#nJymhHfLqS6WnHj>{Pc|=NvrIZO#y}R}+-8&S;mM>Ir@PH#;taRh z@;sdBHuJ<&+~(J?xnvW4>2iU&Kh13}x}dqg8Rb_gS#GoA4=7ipJo5v}y>9a-DQmZ& z4EPZx+igDmBg&wyDBg=GIc~GxMHJsQl#Nnmx=qg|l+7p!2b;cg&$!$ET=H|=9jp6| z-QX(zd1LR^G270(lF)SF%kMpN?~kLuTo-%K{0T1=Oq-XG`C^kBsjtr1_WWCCE0tdM zYWH?OCEW6HWVYKJqH>epq^9kcsA(=<{s|>~J4)(LC=cRiDSM<;{~2XIp8gqS`dcXb zr7XbLzo0bQfs*|TNC~@zgEWd)X)NP)Vaz#qQRg@>)<`Y*@*1n5!P0Dh&8UH)V zpxr1Nen(ls?k2_e9!dpmW~u{^7|-Zf1*5t!~R4G--EJU z%5ymE8p<9isn<}};V>!F_o7t4j`9KyyN=SR0A;_F4LIxu$`L8qH&9-}VN&MpbB8UC z@bvJ!;$A$%gL6K>K7suj-f|!yXlqLI7Vmv6D@RkQ9?n9IdQnui&5R@xY zmWQBh!&_3;9zaPbg0dZN6+s#F5y~|wJMfkl#di>8gBN8N-jcFe%CMp+@8F!GD9InA zgcU>CjdO~jgdalLF6Dik<3rgaCDn(r7w4dO3Rty%cpt72f5560@5eEr@Q18g@d4Zd zoAW-UgoxslaFBUa93}EIloL`8;h7RBXQbqpKsk(Oq%1jt5)+2<8J-D4i93pNLCR4) zQxfHhl;tH+j^i0AYmcEMltMXyXG)Y$Ylntd(zQQw7HcJ^+2IUl< zDT9)H0wt_0%C~r?EK2wnDBGo+#xvzm_DD%BhjJFrNSXd6O7-$6-{YI|D2+~{?3Z!@ z?^HlJA|<;5%8&R*%Dk^oA}XR>!ao&JBELpSeT1p?Gdr7`=zE0y62i>NwGfU-$gYJ@%G@hq-g$(G+6ZOLtl9{X7Z6TJC}%dV zgK$PdejS7g=1~bten5z+i%`j&Ul$?nM}!L!Dw|RD5UxmAUJs$Fc~-*OiwFtz5vrR@ z>mv-hgm6tlO|yFg1m8~x8yX2)CPiCCs~w5YY^w zg_+e1A@T~s2?=+YjUy1wNXTag;#_l7!jh{9F}EQE%=xz=#QlzNK|+)nbvwcp3CnLs zh&InkSo;S;LUV)|b7^ygL4P7#lhEGm-U7jQ4PiqIgbwCa37aJhYl+awT-OpI`8q<_ z9SB{_!FM2p-$2+dp_}P%g|J6LYAb~9<`#s-(>=JOHFgg(r8Nl}xe@kDNH8n6K{z5I zyA48bbFYMX9)yShLSK`&{o#%fgcB0_n~ft8&Pd3QL>OQmm9V4;LQE9GUFQ5Kgg7t4 z1qp-AsI~}KBrI=>Fw{INVQo=_glL3e=F(_{LB$ZRNf>T+Z-?OXA#7-ekZ4|&uvx;e z7=%&gx)_9HKSEd#A;}yZL*pA>C>oG&yv*z|lNo|jk;xV^MnO+>FdMwI#k2x+D zrBMl#{ZdjrW|a;oN2FwTKuPnM1ybgPp+s~29%*Q`crdLF%-UDR;{^^0zsFJ6o`C$)FyeA+3^h7yQ$y37oqwvwE=Mj%t zM;~zJg_CveWVY9ZxFj7VvNFoI`VeGz%$6A_XQV97Kw0cDzm~G33QCtu6w_la%0!8) zit?+Jr5>~66qGAcu1!IC5(i90Sz8Td!&H>zI6%sv>L|mep{!ug)J$Iul!yenvWh`V zK-nzigp}2^uop^lO_cmzD9`qb%=(@&bb< zrBNMESk8!sOGaeZT@`G2YWKpvzt;?IC@5O;;Z9*0-PXIm8oA=}4uf(&nRW8o_PN(e zJwCop$eGxQ-<>$&zxv6#H4oSK&-i#ug>gKRK0myBZ`BX*FYy9y^rNv>%gFo!{N>-)HC#z3; zqGNE+E#+>!wbxY!F4@!dm-BaA8c}Ce)3!Fqz1U-bIN^(G(KyEt;` z1LZ33nDIo^4}a8rrDTcM7d`!G`SQiu4&5DD@WT0YGrvCFzU|SR)4BKG7@76Jn&`YS z-Lek{J~=z0 zQ*xPGnzzYg{-l3?iEH2qODH{Hl>5P-hdi^s$h+Oz?X1+S)}974dd@wZyROEis;k?M zAKUEA&)#Q;Umu&={BrY8zrNZc@~#t8&bXVr)OSXu%(Mz~&fik*7To!?Rutu$%U@u% zY-0w$pcT~+C1E|vcBaUBt*AztB2spcYXgd}G0KJwD7#p7QZ`E&_9Fk>qVKU5UPby( zaIIKBC#p~H-}*0pFzfq*(WmV6uF7x}$8`~RPPl_qku43ztUte-t#d*Kz&|=v`QgIX`6nLzvCBKZez9%kuFnUr zZFGE|ckq3)Ce4b;{eE$S-g8d6E4;UOdc&@r7DhBbwEjZ&==cjiep4y;mVWQPwROD) zKG__)^dU#CpZdud3&7Wx4EakFq_?-1^nmp%AUtPJ}wIPdVpSa%V z;J|9b=Em0Fv2V_to%2pd?x}BvHKlnw@9$jg?1BaH?~ZSq7_(_vNYv_S!{&Ye+wIpH zJvcQsCZgH0SNzdkzc-$H|c3(}U%gm3!^;-x^j5uXz5Y=W4usOY`*Q-@>cN zo?nM-|8v&l@G(!nylq3u=o;BsX%B@A^o}V~@|(NnR4BUZ-bFXA^?W+E@&xbU-c=8+ z2>G*G`s&VgI)(OoGQH7~9$Pcn<7u9`p&!eAFXN=;GrgI{slVsI8)=?!H?K@8;x(w> zZ^mSLPMa6|dy1IN26-YCVK%?h<#ICtC_t9dj?uX%&lR4K`Hl<8k^8c zdMt5VN@j{WzO=!n8h3jLGd)E^=+oy`Yrhy_cj3&4O}(ah`nfAx4dY8?8ot8+5A!p=g=KkelQ}8NQ^cD% zjxXxSlc!ZiSmFOu{A8-rfCF}83;*}uM>5q{JR4oa>RD_qt!>N;cuLvsw!{&$5kKW^ zI+j=LE|@Flc)sy&UG4bb1V4tpPWcW$%CCI9%zez$V$-9Kc#=K#Z%Wwy zU&LDz{O`i_XU%`D?0;9*|KD=aw{!n}-$=3ET|C*@ekZWtKWiBhV}Awj`VITiKpBh@ zE8hBY!r$$}?;Prt2TG$qGHnPHUhHTZaMq@E>6HkI)A!V(Y>9siur+;WEy!2UtZRuQ z>dR`~Y{|9M(e!;&>ALi81r?}o?G=70?ny^uIo8&+Ruo=4-y`ljAsOL4i@JL&Yrw;_(EgP=U4m-Y;Ev=<#s-|idZ zXfHXM{+!~T5Xb*7J7N{YKM3p6aZd&6&m;6!AbC;ms*uK;*<7s^;Cju`suPZKwAUSt zo!k}UXgWwKsILslJG$QRF#Zy2f!Be?W|Nb!HsMmv_OaQ~>Y(X6#JaXPT3y27j-wiK6Zr=vB(o=jNRE=Oxj_+ypK z^){NKHS_h78+qehN7MHfb)?kwo|Cs3;qGWTa*^)pijM#vIdfr;qv;#?t>{S|Sg-t0 zy4yi(r*{R8)*P*kqwRCF7HBom5lS z9dNYPg!L*O_533=e)O6`*AXY3-aVrB2f$HBJM84;)@)TQ4;8+fBuev7uaeUBnUk6{1@It)q1y{1#B7^nw(A^lC@f4o5rVr0aye%hAp{T4(IH9Zi3V&yU`6X}!t^ zvOI3RAgL?H4{8mU-lC$Qc0qj=MlJop$s9*m6H5hObkcETag`>lf-k{xe>~9pffQHx zHkKZQ%RAbyuvxBJHcH*Hz19@ZZJc^-C!6P4idpg zFiJ0@9!($#i~(bT-fpu1JOuKA-ik97Oaob92H?))N(3Xp7?2F~ejwd^Zlk-+K?~5* zyw*OXYHi(D+5-NMGgmvHdx~Cd_X*W~29BC#I)_C2KO$Hc)HbtYLTXmQzt$gi-a()h z(AW1HfyPD6FFJ=*%+c5WFXH1%K<}p;1I7Zq0_FlGunX|5CrW(Ea(infjFQ$X9Z9ZRPyH7x6R@hVL-P_ zy{zUsa8sZMOsCaZ;3=>QJPlTZHDD2V4Cqd2f~DXAFdtB~^@aINcpOLuDWEK6M}f8= zx;(#PKoF$j+KC{|+}l2+ZcY}#`#?6hALx{p3ub~@U^bWo=7I;ngJ2$*5AwhQ@DR8Y z3;=qK+(Dqb^}ApqqX0kkE%fd_oB{ zAf#jBI-pbiP;fW62iyU4ZqYgAILHGFz@r-fMFjNR<1z3!m<{HF2f%~iUT`1C2AN<4 z7zuQi84QMkQlJE=MT2#S`IFvV1A5;|fXelXtA#}AU0J-0$<+qvQ!=>Kyb3hoB?fDs@Oj0DMG92gHKfK)IMq=895FJu}?W7fla;Mb(V zb2a`Cf(77Fun_1(776y#;3rm<)!4vfwxkNu}U4FbV9%b2>^!fQE$iIrcO3)uhu0t$KQE=Mf_60Y&J= zIeLrF>)2nANN;<42Ky!OGAKdo!$2jV7d7dvP}fMag7hoF<6s_`1;&Gk#I*yLX+R(F z4wdS&NJZi{g7b2RK66w9H9=$W0uk%MGVr7lk?|8U9Rr1N9}u1j^wzj7q}dE!0SXI0 zk>^$7qhOup{~&w~Tn9RME6qeWXFGwnzz(nz>;i9t7&0{l%|J2W0~g4kgR)v)0jL-6 zfp>ro%Qb)w$a-Vv_uxEG8Xb^zAg%y(2CfLg!OFuPod$8)5B34r+KnD2MkV1H7X`R5 zUG&oWBwQ7)48nm*Rx9wB^+B@~ToUMTsbY25WH_w@W^q^tP8~E$JDSp!0b(6O%W5{3 zBcKhx3Q(lFB|D#1=vKFr@H4ck0PF<~uxkTtp}M{4HuoOb4Rrg{fbRs_I^P0ozyZ?k z1Dc(Cz|o_#zW(x@p%($13K9}1Qvj~Kr`(DFbB*AwLnc!8_WVTK`w{} zYA6R_>%7?)?gqMoE}%VV1gu9?Z3#qyNN^j70L?&sPzTflbwN|m1gKy=(rOH=5Un$% zlO6!Afhv~W60`u#!5yF#Xam&1b|5FnFD2{%VnIjH33LX1Ks@LU;(%JMj0vCz=n0gj z7w8RChT;c;0YLfs19hw)xD&)Wkan-ee+GdOU^o~827^Ih7`O)v1$R641b7li1>?X- zkO)SDQ6L431xa8GNCwI?9!vyjARSBwQ-NROpGlwu$N;557Eq-0DGpDErvZ{%@1?&F zz8{D+FCKJwzQcJAKLBq5o53cq5xfpw1Fr(pjOZQGFvosC`PXk{Q?W0C4PZTZ9;^dv z!E@kQ@C;Z3R)eR(DzFl)08fGCU>SH4JOP%1B@PspMqG}SNQsq6nO^`^Kw0n-C@9Zm6nb>+s5cN~2u{5IGH-UrIBjuyzPGWLTHz?7 zI18kMnxF>Iy+}7n-7Fh|+Mqfp4Rk*FhxD0;NsQwBrfs5b} z&?x-~egNme_uvRP2R;EGgM*+j?J2@v0i`(%vVj`#DfkSW1joQphrfVNfX_kBaejRX zsUJk$N zf;vDmR})5dEQw^_2TL0dbRug6G`3^mF<=Cc9u1m;CZI7;z9?8vlUsp1KoVL@xCLkq zZU;HH@k^(pW}ppFrIBy|sJD7NARY$dKtG_3wjh>R5m8Yz_L4-6V(+~q zCKjyG#29`5YyrHAN#1+;GM?R?a^}p;nN#-cg|9&u3vk7?;(Lw@Jzy|Ej63!}KZpe= zfy4L8b!p}ycwYK`DDD#hoWPQdaKmvw0>DXW*avX@oMojdnw1t@`dZPLdjn^?B>+}d zX+ajQX9H#dW&$z+N*0`s>kPnDz!X3_U@~A5U?N}wFA2xvVl03ykpdV4NCu1si~_Kk zSc%f5VjblfTa2$uKTk8dA6M#Lv3SMO{a>lXm{GAn=?aW1EPekqDqB=vD6M+e<#C-8c|vnTW=45Ex_(z z^RsJG6~C}X@Q0`s`-dwoZCW-bw?t`yF@8UAii@~{eSp%k6=h1xgp5?NH6PNRTgieL zS8OgLr6%B>>3Hu_3m5?C59kZ%4(J2;9MBD*0q_*>2;}i>=ns^7)Gv-!MYy>S;JpSF zARN~Z0F7|_Gs?GsH-M9XzX2}*mcadvl2-r?0nc&$4Db~21aKA49;3Vs2*&k!l!btE zfU|%zfaBc%j{%MXegZH7Ygie;m7M^b0>nYUX_OZLHvyLb7Xg<6HvrcFR{_k!gU=NJ zKff-+EtK49mdlOh#&LsF@8TbBAv4|q`~vt5@GIaR;2~dw@exW^j<0#{$Me2dxPJ}! z3-Bj^P5KhR&-tE9wkFetq2$V6;QBLMw?)~N-;?q_GMkzG$CD3kJ?}4f26O^+1attj z2ZR9FpDe&1JbnP?W#xnMjQ1IM&!I8y%}@#emdE=UysuFXU?N}h{)D9l{x`>k1%Nj$ ztN;}N)&Snz;O7CTz!qgi09VR5e$Ja5OrzX09pCeY2g_1;`5Du)Y^hSIPVt&|;Hv=I z0X_v(1u&v5pbelkK&gNgZ3SonXbxb(L4alerGnCm@~%!}fHQ!%5P56J2f*7z?f~9w zOXcl7-ZkY`^Cq`Hz!%^R@B*+IJpsHmR0rSz;GH^GfFnRrn0N442`2zsiYw)vJZ?-a z06%jB)CAN3R0lwbR8=)xFp?P=n1M@X;%h}Pw~RGrp&zPP!GDN{pRqfbpRX+eY)yOi zKi{wmqz4iqn~4b(h1k@rq@Vnpc|`xBRD2zX>(V}EdbTRdVNfb(SO7pW4t1mEAEH;81P4;9E_6Z{(S%mfFXcnJnszX02mDz1sDk!!Tuisf*6!+vOy@L z0ZK;WZWsmNN)%HL#61h^2;eTu8LzYj*|eM|l!Vj_*BqEiZfJt*!2n-?=zlghM{6t~ z0AMVP@hl_`z=_8RFdV=EI1Dfp-~-@X!aaohc|3r-X%Zk2kN_A0VA(8>W#BqhycmNU zB_h*g0=7(J09V4oIVf3p3ZQhdYKr^P6)MVeDOVy^$PHwfib5P;e%$}Z;(|@WCY=Ic zAAJo70kB(m^io{IZb`?pU;vwa5?~^L2b>8g#{){cN>PUMiyZNyGTgmcp(d=6KY-27 zE#WTZ3-AF<#(gi8Ne~_kP=s+0QLYuip&xk0bo@L6a2Rk1kPp}l*a+AF2>l5aKqN;R63H&eT$mDD7}ROYG5iNZ*<;;MA-o8g;J*UOry%b~lii z0n>APM&fB*d0Sxoy!;rmSVeoMYW-6CMn%N%PV({FV}06J?s)+e0bagdh?bbRei4aL z(D~M|Cf_s&IU`}}dHMM!jNo(Ba6{cnCR2PGe*;WCulg)?6xwcq`pmuTQa^3iIT;vV z2{REG?AoQZKG3)Cv0Bd(fB}b>6PS3B&q{hM%*|XeDrU6E1T`skzILGDII!Hfzhl&S z@zq*g0yY3mK)>M`Dr1@qIMFY8^1$lA`1wMnFWx?q)dHhL2>75#~-5~I`?7< zkwB%>QcqB5s%a@_q*fQ|sYT#{GAGVAKc=W}7AXB;LUa~80mBI}-PMaPw|TiF9+*JM zxCen@y}O)#P<>RKO*dfbNj$Rj&@yy~)bb{hp+da(FROCofE{=PB;F75__8LD+6%gB zLQUufN*%usWz9~8tv%81`I4VC>J)B%0KG}q>d9}M*1o*RSKfrCpVsOsf4~LxynN|( zy0)su!IVx!X?2ELrjlMWy}M7Vxur=1Q&@~M2J#0mY;8yT8WqR7cRmV?Z+#Kd)Rd}5 zYyC7Krql_gK3tX&{Kw%KlSj+H0Hwbdf(m1hDP>2amZ6~FV0h>if4buA!bWI4!j*$= zJTNxE+&St#Vb_SOqkv(L1tLmJsfc-3f&yE~X$!Wch5qEf=Q$|2)lhOLF!sRA6c*Ri zZ}jLcV&FxLd8X8OAmZT{P@uo3d73upwxfmmN(nE%x{U{g!5$7}8D}S-ITqjIldB?y z*kL??;RtD4ey~TMhAt;%Ug+H1jCK!1yF19do*UM>b!?p83>1N0fq`BDs(xnFeGn`( z%#5ZF()QO+2PMa)?m)SQby`g0XyZ22Lt-=|n;7^w$BaB;z_G=Qdf{Fl8#yA0jXn18 z^NYGD07H;%0iUr=kqePkz8~+-|H;_0HGR)_97oM}Nm)XF=hK zT6+_?yNv}c9tTxIWn!l_sn>JwZc%}lI|_oSy9MdvKoJQF{GT=@Vc@Q>f`9t~6mr*% zx1dl^7*c@d=J>=NUog{QOdwYnD8@{NjOn({{a%HM!*&8=kfblPpe&ZY78I4h+u_dx zTXqV47M9e%%YshD!9^aHR4yK6Z3}7`ukEja=4l(?kY*KVXFN*KodaGU1aH_u(zjn{ zyL*HzYbr~EL%y)4>O-{cHQCmb_E>92uH^+mlWR>GL%_bxnsW7mj&={xI%=NUQ08;3 zj-C$D2I!yJN_I5==H{gdo~|*k2A$0Y6jS@n*|_u4;p<{8KI|=98MCMEqDzgs z=z@VkALh&q*VIjfNhE*zX--$BIf>f#`uAlaAh~m^<~Y#nM6~vX165AarWv}!?D#(| zYtoLEtN*rIiQL8>L#;VFN#j=5d8?A9otOd)w;E=IyN@QJRzsC}L!meun**>*_=C5v zG9zAyii=(OgBTz5Qx?GyqwkGb0C)C9_67GGC+-{sULP-iu6~#!jT?$eQXOgQP&6qM zl&C1}UdO(pL#KX{B2xN#HNf)1iK-5RtUlvOqP!-CC0AxChW6LlJ<^)TNC z3Qnyu2W1V@I_gcZ=gI2tb*xctNAK_(VDpzOVC75?ECij#!I_FsfuR~G*+W%qZDYQ0 zx8{MGDPa*mU^rKw=)!Pl zW$h|XhSF-glH~}r<2vp+p>*3*WU~0$!%rZLy@wzQaHAklX!g2MPb)#EKjtPWw0h*5 zIR9*?=7N~sStI{b6&y)u_AS30oY4T=FQbZs1(pb71!Cc3^*& zb+(B{orWiznm;h?@=O9PDF6jq+SmNaeUCPFf=0a@PuM#j7;X&`4>a;ZeHyu5{Rf77 zIU^_()i71y(0tIZdzY(?_qKu^8X)Wu8(ZBe53=;8H6%@MzB!irNkE19qS?jBbFM+L zyS0^t0F|KzyH)_IU!vbJGp z9w<22a#uf_+-c#)!zIJZc3?PlXjVQx(m!fj9gUj%A^N!L05EKa`oTwv{1!QmfaW|g z^Yv8~dQeufR%dq?6dc$7v-*zO_f7T~jrx)Y9ZA-@ral9P{W8|~Y?57_*<|Aw$0K+48z}9c) z-Egr|g+&$&=f}tf8PCk^$(s^RZZqqycIrV3M z>vJU(!N9PcABA0=YZbi3p#;;78E`Rr-aFlvGBrI2Ox(U|V*T2|!tAsPamvWg>EAc)Xj4xy_lU!Fej_>o3AAan)>hM^F?}}?Yz|H6%0!5>GAEl!5I4LD zHJ*g$ex}rG5}yCigl0{`paA@hNvQtsW;E#=tw;5WK~h@y(0akos~$Wd&-JNX7UnIt zf~asV>b6;>4bj9jr|?zq|A^*NdgvYZi_fOD*TOaG`W%_?&jet&5$6_OIQ;VFBaD3t zW@dBp*#bSXQ0|Al)`7AjC^Obn|788XDm=pRO2XGiwX-?BMlG7-&B^9Rly|_f8XT{{ z!D&R>aP!8-1Ls}SsQu&wU$F)4{vMRxEhq()nocdq^(QEuw_01b`p_0q*jz%%sx|GU z*?H!XDNlzu^3a{`wwSjD^8W+@M|IY-#Vs6~CNCBVd^!79Y)JuYVADD+DR&_#TeOtg zA2_rZ7rN>9@tbth-`EX{di?4WX zjOi2SC0SF=0T1fBPU~l=*;>kA)4v)VEi}H((_%lw4=j2P(s}q8d-?}`Ijt9K%tIqI z_H9kO*Fhh7IHjU>TD@j&YqHEiGp@-3-7b6gs@2QwF4&~WzG^@l3d(`{Bim3?4(4vp z+ek`QIW*Jk>cqTtB^7Af(iOnsZhSVr}g|YbR3rG3l0ebOV$IyI`r|6W(sg`b%dY3kr@T z$Z`RO9dYNucaD2q|K_Epj~9XzJH(FUm0R3!9fjwjbM^Y1lJQVK;B$#>P?I_7Lr!1$ z5p2Ab1A7hlobs3rHb0W9^$UG}U~bky3hVYLd9=9SC?evpUx=Bg6jpxqRpBy4f$sY& zCr-S%1B^KRtFIc|f!c3?k>rJsqj6l@sLz9!jX|##VrF|6J%mTX7g@VoXSBMH^(T}R zr$@SuRLD!GdL1b%4=w86Q5r*@d+gZW-)tM!p`i5jGGLJi(*8l0W86JIhFOIPae^o6(& zm}XvRxZI_e^_~(6MN_$!kF?3F3%PDA)u!Ms;=$ImMFke;XZSuv{!v;V(}iL|X&4U* z&QSGM-2G%#?wd6w6cy1Ucw9`(pJmZa=b$OUEbKx%!TS+C{=KG(9%a{7wg(l<{iEj$ zWqYn{=|;BH$eL<`!l>tFw7zo~UK_$b?mt>M<~B-Mput=xP}~7RF3;SI7`;@7LXW^3 zLN~g`98TCFd{TG`t)pT1S5gP;GAY7p;MUFK#b%0Yw=`fl$sWG< z;`-B!rC2<2`;jwL8DG&+9%|=*MIl>Je*4wDtr+>Yfq|p5jDZmSN@0+9A1H0B*4Eq} z?;BjehE>gCmUgKHZQTm9ruHVGK)X=$q&H<3AoySRma^h_^B4VEKH7d;w5T{M$dEOi zxAn8-ru7cKWas+(c-6;dYB&{vS0k^Oss^e&Bcy4;ihjMvENdUv8B%y#1IjgxpvKz} z2%kn!JW736P;xT5er!mO$G2A>64V%_u#JnCV-d7_8#EdN3T|+?V6AbU@2V-Gm;($? zal6=0-85;UPyG_i+6c1Tj_}$eQyf@i6YV{gmX=VQji4Y9VGPWFSR6i%i9~*dfw=QmS77$5POA*{prmPNEqLrdV$ig0+ejwZlhN0 z`rZCoZVBZ9U^vLz-im4Mv9Dlz3Fc&f%HdkBfx;RTEjo1R@cFdayGtk@_NN=5(7f(X zg*#E24j{W7+Eylx1CUlJ^@+AJCGOC==|iKWMNEzFS9Y%DG!ScVMbAM|lm|Tx(?G$N zZf>_O+Hpfy-sYFb&VO#>KkF&$NBwqUXRK_2vY_JT$ZYx-1En6iXGpEf4^o5eO4OGp z)*87P6tW9L*r$V}{`BkWDc0xCmE)HJ^7g$#j!pNhU9jv2Hr)d;?LvI*8bqgdAp%29 zqyehWkm^l>EUfE87WR*rVu=a4?k;8fcSnD}k9bXKR#L3AOzL&Rwx=>>?A03dWzQVP520&&VbYojQi|x>@u!don|z+<@C+UX^GzVzeGptTk;3=E zv{ufNn_M)vdUY%+VmH+XCGzf>M9SR<^&LRL>*t9!J!4`fsHNFDwubOd5sO6b67O$! zm63rB@8@jAkwF-jc$Q*4*K=rZ#}T$R;wXbzsmfs}rC=RnFdn%;ArD=hFk5m5Z~Tl9 z!{atD=SaMqp_EZCeWlItOGFAx zWw9kHtxpZ3MoPoXYfbvIjBmrS4nll1;7EMmzdT1_2P1ldPHXMT@pb1g$~=aeW61jQcwx?0DdU-GLJRKrvA!cmk53d3`m;zshXolO^DXMyGJMC;f6B$NUO!a0orGdgw1D`?EhVFUI1H+dD+-$-WSi?X_2rwSN*kro? ztj}IjQ{)x1(}-j$Wa-O6fi1tZrAP?>9L%DR_#Cd#Bk)L9`Giuuc; z@0v=n*x^uiI*jG%Yo$r+4fV(dVOFlYn#*yGm^79s=MlsEu_}kE>iz6E1JXIeDy=k@ zsXYG&EsM4Or&j-O@%ZyKb+Py_u1DSs;8n**t!#vUFH4A-T%2kuEmbTqY68WaEUD38 zJbp;ogQM+ztD-N9J=Iud^NI6Ll!#PzH2!NNj6>xEjZzjW#tO)d((`wPycw{g!lK$= z9=g?%)3`X|s>~)+cBy7-RFmn6gHN7J)lOkpH%*u3vj@}96n1}p0-Zlao=H|t zr}$IoV%0&xyL3N?6sc^OD|lCa2>&uth!Fooo|xA6Y` zsnV4CRn4g1hm2~2X3EZ2{XLc5oP{COur!Z5qsLr6KEUqBwIvib8Dw(?nXG1pw1_#s z#^v^|fOW4V3QUnyje+5XPnK28$@JhIvm^{J4LfI0_BE(ID1-K1L^&aYc3wc4ok2N} zso9f3F^s>FK{xmwyUA(iP~xOW>;;@rC^t>~C^+r?_Ztev__T?p_?^g&X_SnXu}q&U zcuiTt@$S8BfnwbhU1fg#?&;DfczNjZR&NglPAIXAk+#*98p_tzNT*_svaSE$>ria` zPwiIP`M<6~ub(N+Al7so(zmtsi^=~N%lu1i|6l7IGmCm&LhMhSCB40}G>>emk8;6= zfzms&XHh07G>tQ8_jOQi1*Hy=zVqd`e~!G=ZG8!4;Vinwl!{dqFDOy;uWj`&V*aDD z-idX&h5y(GI?SQ@SCH)R_U7mnv~$rMX)|F>2j`QEXPw$1CO2`VkTYlARWwsBl-3%> z(En>2l>TWskE(L6kS(h(JL||rhrW%qG8y55VO|6a$@ zdD8BLlTGJ8CTv=sUJ{>*75~|KSLV@`8?bX3Hu{KdjPxzeZe`~~aJ&<*WyF)qKV(+% zR73Sj^QB>^?7&dO|L0EmFPefQas6-OHC4(2X$gPpam}k9_2Mq0>(t}7m42$3!0-@T z?&qhKx@hf&0fW;Zd>r=M1ypz&XIXZDf(KGtb-OFi3kEC~yPZG33_Ar3C!Veg?xb1k zvsS~g;u(qnmDNINvD^P*vrSz*>*H|eJ<#$}D^$jGO)47M?XCR*jk*?|@M0u-A%)(- zkUjzTuDCyRwD6u|XfLl4-W9;`;_&g|Tu-}d)5({HcF%r*H6;PB%MY%-q#G2yhGz{ZQ93W z=H`1MB~H2G%zaC;x{HVoUPL*wA+swed7IO2>ZMydBP-7SS6s zoSaHrM47lZlyQO$)4lPKWdm7$F`J(qIUa@8qjE5C_9D8*yt@WTZlAt%Qi!B%&w;`T6iq_nM>h?$6n77Wr!R0p1| zJ)2T~(-yMKkbB^e_s=v@OKIFa9C|#sjPBjTHvZ?!Y0`Zjaikd0bX!hmaBmp4LYk1T za|>JcYU11KvevN4cwqPi#DhQjTzc9^<8z_j0cr4*6#=L8)* zvn@;qO5RFE@S^1_9$+q*018LQlHZ7Gl=q(PSCi#KXw`K!1wDks-m9rQ-$$>e>3lEY zaqMrk)c*SOJmk;l3uEU?*!M!?%lKI{uhg_>5OsnKj9`5yq94XWWMz#H8 z_>D&2iN+Hf9-KpW9$~{%>iPC24e+VWI%@wI!lc8bC7y`Lp~A-ymIz_oOB&Z)?Q7E_ zbE)hJM8nt|{NXilPS2tEKcU9T914Acc*=)bPjJ?#_Ik(2p zbc#8Xa_R9~aE{3(*QY3_=TgcGl(+Gm@8dU6ESD2D(20tIc=~Yh26|H&ZEccAI~lbo zk9@SaS6Ji|uljp=QsvJ2zf)(fy;~~mpXE`uEe^;oe5rNdLr01vSpuzihP4#L?tKRF zMo8sAtx~PtXOooX9~Y!&E-?LB+Kd<9hsAH0yZ3#)6h^w@Bsj|L(v@ikWBWL4rfd_z zt{nR^Vl&;ahfUDiMsB9u-=XZB%@p(srNXv$Grh)h{db$CiR}r)19iwx_QA3X!FhZ$ z`LMx_n<+cOa0i^WaK2U0yn9ZgJ7ZH$Nd+%};mw$b{qFqsB7JmwQKr~%lYBbLG8ND0 z^XI+9tzSOHSAyC1KZkiGhw94m5`wFgmmD>X^QkIarfHW?Cm>57mM`Uai;K_ahHbSN zB3E7y-&^O?VgZzK;P?X(mYPp_m0=}FoSRQEe?XC}dE0EPe z=aRdIK@SQ}G`gC1-L7RWbQdYacaa8Q*utmp4Zm>Pu>&?l#q+ zs*GY~-WzRz=Gm5c0&E4zvh_7#`E-Tl*cM3o?-+5fwYb&j-e$hNvNWhLtIG&#`)Fwc}ZUJQ7JiCvX{LH zqDje&*>BNxrRSy1DyDFyx69t1+ZgH1xEfiGe$+0>&v_P?29KDy6|Wq`tb(|EH)-n6 zg3u1`PI$BK)wx?4HAHudr^4g}B5Nal0irZgi8rM`D+(*xDE(h~E@>_%Ql+6D8fYKB zm%{bv4gq42i;a}=#i>n69dgxlgZU)6qDsc}N|h45IG(}uHl~8kPu2hm8h`z2^9f?`a>T<3YyQEkvdbNU56ns~{G!V)GOFPR6A*DZwsV}!! znO>AOx8~&cw7R??&OkVQ$up4BB>I!zOGEpYJ=FRg*9ykVxkk!)?@U`tx{sgISCpwt zSyB+||DsLBfhLb#G|CJCcKt`1XD0Z4WW{y)5V=|+Fy!@%;qD=6*;qbg^ziuFMJ;&c zC4PU26^68IyqSJEX=BI{Q#2B10C|%3_7Jr<$MB~=EFBZkT$z+S|7(97J5+cZ9i}vM z!Na8oC^&5!FF|e8F~I2P>aSYI2IxgU@g3-9UT&w}bTSnpssq!z!?Y7Eq7Vzg zwz^W`#{|*@3%FOFOw%z7bO5X|@EwewMz)S&kf}6#Us5BjZk>sdeQeZCoA7Gd=Odab ztGSO=U6#G%HfZi1qH~rddg!0P2UTI0#GPH%-h&~Wk5t4;<`m$>)Z5K_C!<1D) z&}mbTs%U!!!MD0KR`opX_`tV8e3lm-3^uXCCpaHI*MKL7d4$h5@8J~{UL>4=zMW`)F*ZH^>JntYY(x-i6GekDaA`pd7-0`XLH&# z5IEQfwaphm2ET7c(y?+9bj}W#^70gC>Y)^i^T)3JB`S~8)q$!W$5oVSQ`}B_7v$J7 z7GX$zm|z~YLC+csW3Y~O9O7s9>^!nalE>4xvQvYO|bbA?N)8m> z>_E6-bWO%eQ#r)$}CGCd{*L5I`!R;ddr%QGCw-7 zG!82a_AYpo31VKFReU^eTAv^_P9b9|E`Eb0`-I!Q1Vok~vUxV%#-V0hpiBgdOJlA$ zn~+SPtfKt%>%e3Nrm<>6MvWH1^JON~sZ6$%*glDgI=k9EwN3434_xg;N-Oowh3Mx!1;gD|wJIVWB`gq)1;r`|7KJ6!5bV-vfj2%WfO*wxsYlyprDX2Hm zQK+p=$Hfvy!9_b47yBF$y1BTx43zozkpBtf%Q;P7c1}|QQ?d9*COJw6lr}Y#`M0uY zSJu&G^l`+Z6cTtH{E_dO_2S>3gXWs^L(-_ZA3iLEC&W>*j}zU}!@BPuvg~(JvaWGk zO099qn8=mn*)pbqP}^AQM=bP_X&^pzT3i*QOZCfA-x~d3z_E1O`_)V}F%e0TiAng; ztIH}nSygc8*b*chEW)_UkA94_>LCROpM~fQ42MmqeritFuHQt;VT0}#A!BCeRB4^r z+(g<)MLNK5=s>KAi{R^@1`=B^H+HDe+V6DQa*9WXP}*PmXsV?B?>IJAT# zk<`IOaMG@XguyNtkPARp5p+(?r_CO8^8)Y5%8UO)AXy3$zq2xloAsD5&+sXEAmNWq zkY@+S#SVyzONf?(rA38A}KTDw*uiZ+g#$(}m{|Xh2$5j386*8TG zEoA*wil2b}gio$YRylLVrF-`^W)(_4lxzV^6{t0@sDJ#yTf$^Jb$vYHR}ej}(r)J6 z*iX8zSmk@tov43~6_@rXFM*Pqw5nux#s;U=CeGIoZIIz@Db_yY@;Zdg-5rQRq(1QFx&gIL_PG6}^wQHGV=Tuo4_l?a?B`boew002YwymNV2@6W91#iH)pftT z)YAhuqyM`he!mX=P6wn6f_=yLgu#EXU^8&!~V$t8eFJJuq<%=hJok z@dzq-dY4>RXxpcH-jhsF_i}?ik@uS8^p4mMLD2{p=3T$5_ro!*m+-qTd>6&MpUN1+ z{yqB3s^|Rp41eowkiF@+w&a)Pw}^&ypjftN{ot(iSZmV6k0=gd|X0OqG#W@7?cT-k)DI1 zV`%AhK~E=A1rz!uMW|$%n3ND5JHT^zbibsifx5t|r| zKj|f$WQ;)9n+tXn+e{cp71M+cX2TOAWRj(|1t+T5OlVDoLBeSoohJB@U%JqcPOKDa zQ#E^rp zVW@da!Hs;Pg$lGtV^W>+`U|xSes>o_)CD7I2+wsiqNz}e)-)B|>1 } diff --git a/projects/website/src/components/ranking/mini.tsx b/projects/website/src/components/ranking/mini.tsx index 0e6995e..3620aa5 100644 --- a/projects/website/src/components/ranking/mini.tsx +++ b/projects/website/src/components/ranking/mini.tsx @@ -1,5 +1,3 @@ -import ScoreSaberPlayerToken from "@/common/model/token/scoresaber/score-saber-player-token"; -import { ScoreSaberPlayersPageToken } from "@/common/model/token/scoresaber/score-saber-players-page-token"; import { formatNumberWithCommas, formatPp } from "@/common/number-utils"; import { GlobeAmericasIcon } from "@heroicons/react/24/solid"; import { useQuery } from "@tanstack/react-query"; @@ -8,9 +6,11 @@ import { ReactElement } from "react"; import Card from "../card"; import CountryFlag from "../country-flag"; import { Avatar, AvatarImage } from "../ui/avatar"; -import ScoreSaberPlayer from "@/common/model/player/impl/scoresaber-player"; -import { scoresaberService } from "@/common/service/impl/scoresaber"; import { PlayerRankingSkeleton } from "@/components/ranking/player-ranking-skeleton"; +import ScoreSaberPlayer from "@ssr/common/types/player/impl/scoresaber-player"; +import { ScoreSaberPlayersPageToken } from "@ssr/common/types/token/scoresaber/score-saber-players-page-token"; +import { scoresaberService } from "@ssr/common/service/impl/scoresaber"; +import ScoreSaberPlayerToken from "@ssr/common/types/token/scoresaber/score-saber-player-token"; const PLAYER_NAME_MAX_LENGTH = 18; diff --git a/projects/website/src/components/ranking/player-ranking-skeleton.tsx b/projects/website/src/components/ranking/player-ranking-skeleton.tsx index 8a27c9c..be962dc 100644 --- a/projects/website/src/components/ranking/player-ranking-skeleton.tsx +++ b/projects/website/src/components/ranking/player-ranking-skeleton.tsx @@ -1,5 +1,5 @@ import Card from "@/components/card"; -import { Skeleton } from "@/app/components/ui/skeleton"; +import { Skeleton } from "@/components/ui/skeleton"; export function PlayerRankingSkeleton() { const skeletonArray = new Array(5).fill(0); diff --git a/projects/website/src/components/score/score-badge.tsx b/projects/website/src/components/score/score-badge.tsx index 222bec9..86ca9bf 100644 --- a/projects/website/src/components/score/score-badge.tsx +++ b/projects/website/src/components/score/score-badge.tsx @@ -1,6 +1,6 @@ -import ScoreSaberScoreToken from "@/common/model/token/scoresaber/score-saber-score-token"; -import ScoreSaberLeaderboardToken from "@/common/model/token/scoresaber/score-saber-leaderboard-token"; import StatValue from "@/components/stat-value"; +import ScoreSaberScoreToken from "@ssr/common/types/token/scoresaber/score-saber-score-token"; +import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token"; /** * A badge to display in the score stats. diff --git a/projects/website/src/components/score/score-buttons.tsx b/projects/website/src/components/score/score-buttons.tsx index e46abb9..1a214ee 100644 --- a/projects/website/src/components/score/score-buttons.tsx +++ b/projects/website/src/components/score/score-buttons.tsx @@ -1,6 +1,5 @@ "use client"; -import { copyToClipboard } from "../../../../common/src/utils/browser-utils"; import BeatSaverMap from "@/common/database/types/beatsaver-map"; import { songNameToYouTubeLink } from "@/common/youtube-utils"; import BeatSaverLogo from "@/components/logos/beatsaver-logo"; @@ -9,7 +8,8 @@ import { useToast } from "@/hooks/use-toast"; import { Dispatch, SetStateAction } from "react"; import LeaderboardButton from "./leaderboard-button"; import ScoreButton from "./score-button"; -import ScoreSaberLeaderboardToken from "@/common/model/token/scoresaber/score-saber-leaderboard-token"; +import { copyToClipboard } from "@/common/browser-utils"; +import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token"; type Props = { leaderboard: ScoreSaberLeaderboardToken; diff --git a/projects/website/src/components/score/score-info.tsx b/projects/website/src/components/score/score-info.tsx index 1d45ffd..74c6b72 100644 --- a/projects/website/src/components/score/score-info.tsx +++ b/projects/website/src/components/score/score-info.tsx @@ -1,5 +1,4 @@ import BeatSaverMap from "@/common/database/types/beatsaver-map"; -import ScoreSaberLeaderboardToken from "@/common/model/token/scoresaber/score-saber-leaderboard-token"; import { getDifficultyFromScoreSaberDifficulty } from "@/common/scoresaber-utils"; import FallbackLink from "@/components/fallback-link"; import Tooltip from "@/components/tooltip"; @@ -8,6 +7,7 @@ import clsx from "clsx"; import Image from "next/image"; import { songDifficultyToColor } from "@/common/song-utils"; import Link from "next/link"; +import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token"; type Props = { leaderboard: ScoreSaberLeaderboardToken; diff --git a/projects/website/src/components/score/score-rank-info.tsx b/projects/website/src/components/score/score-rank-info.tsx index 4ae20bc..ac8924d 100644 --- a/projects/website/src/components/score/score-rank-info.tsx +++ b/projects/website/src/components/score/score-rank-info.tsx @@ -1,9 +1,9 @@ -import ScoreSaberScoreToken from "@/common/model/token/scoresaber/score-saber-score-token"; import { formatNumberWithCommas } from "@/common/number-utils"; -import { timeAgo } from "@/common/time-utils"; import { format } from "@formkit/tempo"; import { GlobeAmericasIcon } from "@heroicons/react/24/solid"; import Tooltip from "../tooltip"; +import ScoreSaberScoreToken from "@ssr/common/types/token/scoresaber/score-saber-score-token"; +import { timeAgo } from "@ssr/common/utils/time-utils"; type Props = { score: ScoreSaberScoreToken; diff --git a/projects/website/src/components/score/score.tsx b/projects/website/src/components/score/score.tsx index 50dc044..ec2ed9e 100644 --- a/projects/website/src/components/score/score.tsx +++ b/projects/website/src/components/score/score.tsx @@ -1,16 +1,15 @@ "use client"; import BeatSaverMap from "@/common/database/types/beatsaver-map"; -import ScoreSaberPlayerScoreToken from "@/common/model/token/scoresaber/score-saber-player-score-token"; -import { beatsaverService } from "@/common/service/impl/beatsaver"; import LeaderboardScores from "@/components/leaderboard/leaderboard-scores"; -import { useCallback, useEffect, useState } from "react"; +import { useState } from "react"; import ScoreButtons from "./score-buttons"; import ScoreSongInfo from "./score-info"; import ScoreRankInfo from "./score-rank-info"; import ScoreStats from "./score-stats"; -import ScoreSaberPlayer from "@/common/model/player/impl/scoresaber-player"; import { motion } from "framer-motion"; +import ScoreSaberPlayer from "@ssr/common/types/player/impl/scoresaber-player"; +import ScoreSaberPlayerScoreToken from "@ssr/common/types/token/scoresaber/score-saber-player-score-token"; type Props = { /** @@ -29,14 +28,15 @@ export default function Score({ player, playerScore }: Props) { const [beatSaverMap, setBeatSaverMap] = useState(); const [isLeaderboardExpanded, setIsLeaderboardExpanded] = useState(false); - const fetchBeatSaverData = useCallback(async () => { - const beatSaverMap = await beatsaverService.lookupMap(leaderboard.songHash); - setBeatSaverMap(beatSaverMap); - }, [leaderboard.songHash]); - - useEffect(() => { - fetchBeatSaverData(); - }, [fetchBeatSaverData]); + // todo: fix + // const fetchBeatSaverData = useCallback(async () => { + // const beatSaverMap = await beatsaverService.lookupMap(leaderboard.songHash); + // setBeatSaverMap(beatSaverMap); + // }, [leaderboard.songHash]); + // + // useEffect(() => { + // fetchBeatSaverData(); + // }, [fetchBeatSaverData]); const page = Math.floor(score.rank / 12) + 1; return ( diff --git a/projects/website/src/app/components/ui/skeleton.tsx b/projects/website/src/components/ui/skeleton.tsx similarity index 100% rename from projects/website/src/app/components/ui/skeleton.tsx rename to projects/website/src/components/ui/skeleton.tsx diff --git a/projects/website/src/jobs/index.ts b/projects/website/src/jobs/index.ts deleted file mode 100644 index 5c5264d..0000000 --- a/projects/website/src/jobs/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -// export all your job files here - -export * from "./track-player-statistics"; diff --git a/projects/website/src/jobs/track-player-statistics.ts b/projects/website/src/jobs/track-player-statistics.ts deleted file mode 100644 index b216dd7..0000000 --- a/projects/website/src/jobs/track-player-statistics.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { cronTrigger } from "@trigger.dev/sdk"; -import { client } from "@/trigger"; -import { connectMongo } from "@/common/mongo"; -import { getMidnightAlignedDate } from "@/common/time-utils"; -import { IPlayer, PlayerModel } from "@/common/schema/player-schema"; -import { trackScoreSaberPlayer } from "@/common/player-utils"; - -client.defineJob({ - id: "track-player-statistics", - name: "Tracks player statistics", - version: "0.0.1", - trigger: cronTrigger({ - // Run at 00:01 every day (midnight) - cron: "0 1 * * *", - }), - run: async (payload, io) => { - await io.logger.info("Connecting to Mongo"); - await connectMongo(); - - await io.logger.info("Finding players..."); - const players: IPlayer[] = await PlayerModel.find({}); - await io.logger.info(`Found ${players.length} player${players.length > 1 ? "s" : ""}.`); - - const dateToday = getMidnightAlignedDate(new Date()); - for (const foundPlayer of players) { - await io.runTask(`track-player-${foundPlayer.id}`, async () => { - await trackScoreSaberPlayer(dateToday, foundPlayer, io); - }); - } - }, -}); diff --git a/projects/website/src/trigger.ts b/projects/website/src/trigger.ts deleted file mode 100644 index a2cf138..0000000 --- a/projects/website/src/trigger.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { TriggerClient } from "@trigger.dev/sdk"; - -export const client = new TriggerClient({ - id: "scoresaber-reloaded-KB0Z", - apiKey: process.env.TRIGGER_API_KEY, - apiUrl: process.env.TRIGGER_API_URL, -});

Q$ay<;WM*> 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 z%0A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 }> { + const player = await PlayerService.getPlayer(id, createIfMissing); + return { statistics: player.getHistoryPreviousDays(50) }; + } + + @Get("/tracked/:id", { + config: {}, + params: t.Object({ + id: t.String({ required: true }), + }), + }) + public async getTrackedStatus({ + params: { id }, + query: { createIfMissing }, + }: { + params: { id: string }; + query: { createIfMissing: boolean }; + }): Promise { + try { + const player = await PlayerService.getPlayer(id, createIfMissing); + return { + tracked: true, + daysTracked: player.getDaysTracked(), + }; + } catch { + return { + tracked: false, + }; + } + } +} diff --git a/projects/backend/src/error/not-found-error.ts b/projects/backend/src/error/not-found-error.ts new file mode 100644 index 0000000..b9f3972 --- /dev/null +++ b/projects/backend/src/error/not-found-error.ts @@ -0,0 +1,10 @@ +import { HttpCode } from "../common/http-codes"; + +export class NotFoundError extends Error { + constructor( + public message: string = "not-found", + public status: number = HttpCode.NOT_FOUND.code + ) { + super(message); + } +} \ No newline at end of file diff --git a/projects/backend/src/error/rate-limit-error.ts b/projects/backend/src/error/rate-limit-error.ts index 50c944c..4b9c6aa 100644 --- a/projects/backend/src/error/rate-limit-error.ts +++ b/projects/backend/src/error/rate-limit-error.ts @@ -2,10 +2,9 @@ import { HttpCode } from "../common/http-codes"; export class RateLimitError extends Error { constructor( - public message: string = 'rate-limited', - public detail: string = '', + public message: string = "rate-limited", public status: number = HttpCode.TOO_MANY_REQUESTS.code ) { - super(message) + super(message); } } \ No newline at end of file diff --git a/projects/backend/src/index.ts b/projects/backend/src/index.ts index ee7362f..179caf4 100644 --- a/projects/backend/src/index.ts +++ b/projects/backend/src/index.ts @@ -2,14 +2,29 @@ import { Elysia } from "elysia"; import cors from "@elysiajs/cors"; import { decorators } from "elysia-decorators"; import { logger } from "@tqman/nice-logger"; -import { swagger } from '@elysiajs/swagger' -import { rateLimit } from 'elysia-rate-limit' +import { swagger } from "@elysiajs/swagger"; +import { rateLimit } from "elysia-rate-limit"; import { RateLimitError } from "./error/rate-limit-error"; -import { helmet } from 'elysia-helmet'; -import { etag } from '@bogeychan/elysia-etag' +import { helmet } from "elysia-helmet"; +import { etag } from "@bogeychan/elysia-etag"; import AppController from "./controller/app.controller"; +import * as dotenv from "@dotenvx/dotenvx"; +import mongoose from "mongoose"; +import { Config } from "./common/config"; +import { setLogLevel } from "@typegoose/typegoose"; +import PlayerController from "./controller/player.controller"; +import { PlayerService } from "./service/player.service"; -const app = new Elysia(); +// Load .env file +dotenv.config({ + logLevel: "success", + path: ".env", + override: true, +}); + +await mongoose.connect(Config.mongoUri!); // Connect to MongoDB +setLogLevel("DEBUG"); +export const app = new Elysia(); /** * Custom error handler @@ -50,33 +65,37 @@ app.use( /** * Rate limit (100 requests per minute) */ -app.use(rateLimit({ - scoping: "global", - duration: 60 * 1000, - max: 100, - skip: (request) => { - let [ _, path ] = request.url.split("/"); // Get the url parts - path === "" || path === undefined && (path = "/"); // If we're on /, the path is undefined, so we set it to / - return path === "/"; // ignore all requests to / - }, - errorResponse: new RateLimitError("Too many requests, please try again later"), -})) +app.use( + rateLimit({ + scoping: "global", + duration: 60 * 1000, + max: 100, + skip: request => { + let [_, path] = request.url.split("/"); // Get the url parts + path === "" || (path === undefined && (path = "/")); // If we're on /, the path is undefined, so we set it to / + return path === "/"; // ignore all requests to / + }, + errorResponse: new RateLimitError("Too many requests, please try again later"), + }) +); /** * Security settings */ -app.use(helmet({ - hsts: false, // Disable HSTS - contentSecurityPolicy: false, // Disable CSP - dnsPrefetchControl: true, // Enable DNS prefetch -})) +app.use( + helmet({ + hsts: false, // Disable HSTS + contentSecurityPolicy: false, // Disable CSP + dnsPrefetchControl: true, // Enable DNS prefetch + }) +); /** * Controllers */ app.use( decorators({ - controllers: [AppController], + controllers: [AppController, PlayerController], }) ); @@ -89,4 +108,9 @@ app.onStart(() => { console.log("Listening on port http://localhost:8080"); }); +/** + * Start cronjobs + */ +PlayerService.initCronjobs(); + app.listen(8080); diff --git a/projects/backend/src/model/player.ts b/projects/backend/src/model/player.ts new file mode 100644 index 0000000..57f8286 --- /dev/null +++ b/projects/backend/src/model/player.ts @@ -0,0 +1,108 @@ +import { getModelForClass, prop, ReturnModelType } from "@typegoose/typegoose"; +import { Document } from "mongoose"; +import { PlayerHistory } from "@ssr/common/types/player/player-history"; +import { formatDateMinimal, getDaysAgoDate, getMidnightAlignedDate } from "@ssr/common/utils/time-utils"; + +/** + * The model for a player. + */ +export class Player { + /** + * The id of the player. + */ + @prop() + public _id!: string; + + /** + * The player's statistic history. + */ + @prop() + private statisticHistory?: Record; + + @prop() + public lastTracked?: Date; + + /** + * Gets the player's statistic history. + */ + public getStatisticHistory(): Record { + if (this.statisticHistory === undefined) { + this.statisticHistory = {}; + } + return this.statisticHistory; + } + + /** + * Gets the player's history for a specific date. + * + * @param date the date to get the history for. + */ + public getHistoryByDate(date: Date): PlayerHistory { + if (this.statisticHistory === undefined) { + this.statisticHistory = {}; + } + return this.getStatisticHistory()[formatDateMinimal(getMidnightAlignedDate(date))] || {}; + } + + /** + * Gets the player's history for the previous X days. + * + * @param days the number of days to get the history for. + */ + public getHistoryPreviousDays(days: number): Record { + if (this.statisticHistory === undefined) { + this.statisticHistory = {}; + } + const history: Record = {}; + for (let i = 0; i < days; i++) { + const date = formatDateMinimal(getMidnightAlignedDate(getDaysAgoDate(i))); + const playerHistory = this.getStatisticHistory()[date]; + if (playerHistory === undefined || Object.keys(playerHistory).length === 0) { + continue; + } + history[date] = playerHistory; + } + return history; + } + + /** + * Sets the player's statistic history. + * + * @param date the date to set it for. + * @param history the history to set. + */ + public setStatisticHistory(date: Date, history: PlayerHistory) { + if (this.statisticHistory === undefined) { + this.statisticHistory = {}; + } + this.getStatisticHistory()[formatDateMinimal(getMidnightAlignedDate(date))] = history; + } + + /** + * Sorts the player's statistic history by + * date in descending order. (oldest to newest) + */ + public sortStatisticHistory() { + if (this.statisticHistory === undefined) { + this.statisticHistory = {}; + } + return Object.entries(this.getStatisticHistory()) + .sort((a, b) => Date.parse(b[0]) - Date.parse(a[0])) + .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}); + } + + /** + * Gets the number of days tracked. + * + * @returns the number of days tracked. + */ + public getDaysTracked(): number { + return Object.keys(this.getStatisticHistory()).length; + } +} + +// This type defines a Mongoose document based on Player. +export type PlayerDocument = Player & Document; + +// This type ensures that PlayerModel returns Mongoose documents (PlayerDocument) that have Mongoose methods (save, remove, etc.) +export const PlayerModel: ReturnModelType = getModelForClass(Player); diff --git a/projects/backend/src/service/player.service.ts b/projects/backend/src/service/player.service.ts new file mode 100644 index 0000000..fdcdbc8 --- /dev/null +++ b/projects/backend/src/service/player.service.ts @@ -0,0 +1,126 @@ +import { PlayerDocument, PlayerModel } from "../model/player"; +import { NotFoundError } from "../error/not-found-error"; +import { cron } from "@elysiajs/cron"; +import { app } from "../index"; +import { getDaysAgoDate, getMidnightAlignedDate } from "@ssr/common/utils/time-utils"; +import { scoresaberService } from "@ssr/common/service/impl/scoresaber"; +import ScoreSaberPlayerToken from "@ssr/common/types/token/scoresaber/score-saber-player-token"; + +export class PlayerService { + /** + * Initialize the cron jobs + */ + public static initCronjobs() { + app.use( + cron({ + name: "player-statistics-tracker-cron", + pattern: "0 1 * * *", // Every day at 00:01 (midnight) + run: async () => { + const players: PlayerDocument[] = await PlayerModel.find({}); + for (const player of players) { + await PlayerService.trackScoreSaberPlayer(getMidnightAlignedDate(new Date()), player); + } + }, + }) + ); + } + + /** + * Get a player from the database. + * + * @param id the player to fetch + * @param create if true, create the player if it doesn't exist + * @returns the player + * @throws NotFoundError if the player is not found + */ + public static async getPlayer(id: string, create: boolean = false): Promise { + console.log(`Fetching player "${id}"...`); + let player: PlayerDocument | null = await PlayerModel.findById(id); + if (player === null && !create) { + console.log(`Player "${id}" not found.`); + throw new NotFoundError(`Player "${id}" not found`); + } + if (player === null) { + const playerToken = await scoresaberService.lookupPlayer(id); + if (playerToken === undefined) { + throw new NotFoundError(`Player "${id}" not found`); + } + + console.log(`Creating player "${id}"...`); + player = (await PlayerModel.create({ _id: id })) as any; + if (player === null) { + throw new NotFoundError(`Player "${id}" not found`); + } + await this.seedPlayerHistory(player, playerToken); + console.log(`Created player "${id}".`); + } else { + console.log(`Found player "${id}".`); + } + return player; + } + + /** + * Seeds the player's history using data from + * the ScoreSaber API. + * + * @param player the player to seed + * @param playerToken the SoreSaber player token + */ + public static async seedPlayerHistory(player: PlayerDocument, playerToken: ScoreSaberPlayerToken): Promise { + // Loop through rankHistory in reverse, from current day backwards + const playerRankHistory = playerToken.histories.split(",").map((value: string) => { + return parseInt(value); + }); + playerRankHistory.push(playerToken.rank); + + let daysAgo = 1; // Start from yesterday + for (let i = playerRankHistory.length - daysAgo - 1; i >= 0; i--) { + const rank = playerRankHistory[i]; + const date = getMidnightAlignedDate(getDaysAgoDate(daysAgo)); + player.setStatisticHistory(date, { + rank: rank, + }); + daysAgo += 1; // Increment daysAgo for each earlier rank + } + await player.save(); + } + + /** + * Tracks a players statistics + * + * @param dateToday the date to track + * @param foundPlayer the player to track + */ + public static async trackScoreSaberPlayer(dateToday: Date, foundPlayer: PlayerDocument) { + const player = await scoresaberService.lookupPlayer(foundPlayer.id); + if (player == undefined) { + console.log(`Player "${foundPlayer.id}" not found on ScoreSaber`); + return; + } + if (player.inactive) { + console.log(`Player "${foundPlayer.id}" is inactive on ScoreSaber`); + return; + } + + // Seed the history with ScoreSaber data if no history exists + if (foundPlayer.getDaysTracked() === 0) { + await this.seedPlayerHistory(foundPlayer, player); + } + + // Update current day's statistics + let history = foundPlayer.getHistoryByDate(dateToday); + if (history == undefined) { + history = {}; // Initialize if history is not found + } + // Set the history data + history.pp = player.pp; + history.countryRank = player.countryRank; + history.rank = player.rank; + foundPlayer.setStatisticHistory(dateToday, history); + foundPlayer.sortStatisticHistory(); + foundPlayer.lastTracked = new Date(); + await foundPlayer.save(); + + console.log(`Tracked player "${foundPlayer.id}"!`); + } +} diff --git a/projects/backend/tsconfig.json b/projects/backend/tsconfig.json index 98efe04..0002c94 100644 --- a/projects/backend/tsconfig.json +++ b/projects/backend/tsconfig.json @@ -1,12 +1,14 @@ { "compilerOptions": { - "target": "ES2021", + "target": "ES2022", "module": "ES2022", - "moduleResolution": "node", + "moduleResolution": "Bundler", "types": ["bun-types"], "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "strict": true, - "skipLibCheck": true + "skipLibCheck": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, } } diff --git a/projects/common/package.json b/projects/common/package.json index 93e043b..fb71653 100644 --- a/projects/common/package.json +++ b/projects/common/package.json @@ -3,12 +3,19 @@ "version": "1.0.0", "type": "module", "scripts": { - "dev": "tsup src/index.ts --watch", - "build": "tsup src/index.ts" + "dev": "tsc --watch --preserveWatchOutput", + "build": "tsc" + }, + "exports": { + "./*": { + "types": "./dist/*.d.ts", + "import": "./dist/*.js", + "require": "./dist/*.js", + "default": "./dist/*.js" + } }, "devDependencies": { "@types/node": "^22.7.4", - "tsup": "^8", "typescript": "^5" }, "dependencies": { diff --git a/projects/common/src/index.ts b/projects/common/src/index.ts deleted file mode 100644 index 11a5776..0000000 --- a/projects/common/src/index.ts +++ /dev/null @@ -1,49 +0,0 @@ -export * from "src/utils/utils"; -export * from "src/utils/time-utils"; - -/** - * Player stuff - */ -export * from "src/types/player/player-history"; -export * from "src/types/player/player-tracked-since"; -export * from "src/types/player/player"; -export * from "src/types/player/impl/scoresaber-player"; -export * from "src/utils/player-utils"; - -/** - * Score stuff - */ -export * from "src/types/score/score"; -export * from "src/types/score/score-sort"; -export * from "src/types/score/modifier"; -export * from "src/types/score/impl/scoresaber-score"; - -/** - * Service stuff - */ -export * from "src/service/impl/beatsaver"; -export * from "src/service/impl/scoresaber"; - -/** - * Scoresaber Tokens - */ -export * from "src/types/token/scoresaber/score-saber-badge-token"; -export * from "src/types/token/scoresaber/score-saber-difficulty-token"; -export * from "src/types/token/scoresaber/score-saber-leaderboard-player-info-token"; -export * from "src/types/token/scoresaber/score-saber-leaderboard-scores-page-token"; -export * from "src/types/token/scoresaber/score-saber-leaderboard-token"; -export * from "src/types/token/scoresaber/score-saber-metadata-token"; -export * from "src/types/token/scoresaber/score-saber-player-score-token"; -export * from "src/types/token/scoresaber/score-saber-player-scores-page-token"; -export * from "src/types/token/scoresaber/score-saber-player-search-token"; -export * from "src/types/token/scoresaber/score-saber-player-token"; -export * from "src/types/token/scoresaber/score-saber-players-page-token"; -export * from "src/types/token/scoresaber/score-saber-score-token"; - -/** - * Beatsaver Tokens - */ -export * from "src/types/token/beatsaver/beat-saver-account-token"; -export * from "src/types/token/beatsaver/beat-saver-map-metadata-token"; -export * from "src/types/token/beatsaver/beat-saver-map-stats-token"; -export * from "src/types/token/beatsaver/beat-saver-map-token"; diff --git a/projects/common/src/service/impl/scoresaber.ts b/projects/common/src/service/impl/scoresaber.ts index 75ca24f..27c8295 100644 --- a/projects/common/src/service/impl/scoresaber.ts +++ b/projects/common/src/service/impl/scoresaber.ts @@ -1,7 +1,6 @@ import Service from "../service"; import { ScoreSaberPlayerSearchToken } from "../../types/token/scoresaber/score-saber-player-search-token"; import ScoreSaberPlayerToken from "../../types/token/scoresaber/score-saber-player-token"; -import ScoreSaberPlayer, { getScoreSaberPlayerFromToken } from "../../types/player/impl/scoresaber-player"; import { ScoreSaberPlayersPageToken } from "../../types/token/scoresaber/score-saber-players-page-token"; import { ScoreSort } from "../../types/score/score-sort"; import ScoreSaberPlayerScoresPageToken from "../../types/token/scoresaber/score-saber-player-scores-page-token"; @@ -55,19 +54,9 @@ class ScoreSaberService extends Service { * Looks up a player by their ID. * * @param playerId the ID of the player to look up - * @param apiUrl the url to the API for SSR * @returns the player that matches the ID, or undefined */ - async lookupPlayer( - playerId: string, - apiUrl: string - ): Promise< - | { - player: ScoreSaberPlayer; - rawPlayer: ScoreSaberPlayerToken; - } - | undefined - > { + async lookupPlayer(playerId: string): Promise { const before = performance.now(); this.log(`Looking up player "${playerId}"...`); const token = await this.fetch(LOOKUP_PLAYER_ENDPOINT.replace(":id", playerId)); @@ -75,10 +64,7 @@ class ScoreSaberService extends Service { return undefined; } this.log(`Found player "${playerId}" in ${(performance.now() - before).toFixed(0)}ms`); - return { - player: await getScoreSaberPlayerFromToken(apiUrl, token), - rawPlayer: token, - }; + return token; } /** diff --git a/projects/common/src/types/player/impl/scoresaber-player.ts b/projects/common/src/types/player/impl/scoresaber-player.ts index dc3cca6..56c07b0 100644 --- a/projects/common/src/types/player/impl/scoresaber-player.ts +++ b/projects/common/src/types/player/impl/scoresaber-player.ts @@ -3,6 +3,7 @@ import ky from "ky"; import { PlayerHistory } from "../player-history"; import ScoreSaberPlayerToken from "../../token/scoresaber/score-saber-player-token"; import { formatDateMinimal, getDaysAgoDate, getMidnightAlignedDate } from "../../../utils/time-utils"; +import { getPlayerIdCookie } from "website/src/common/website-utils"; /** * A ScoreSaber player. @@ -65,9 +66,15 @@ export default interface ScoreSaberPlayer extends Player { isBeingTracked?: boolean; } +/** + * Gets the ScoreSaber Player from an {@link ScoreSaberPlayerToken}. + * + * @param token the player token + * @param apiUrl the api url for SSR + */ export async function getScoreSaberPlayerFromToken( - apiUrl: string, - token: ScoreSaberPlayerToken + token: ScoreSaberPlayerToken, + apiUrl: string ): Promise { const bio: ScoreSaberBio = { lines: token.bio?.split("\n") || [], @@ -86,10 +93,10 @@ export async function getScoreSaberPlayerFromToken( const todayDate = formatDateMinimal(getMidnightAlignedDate(new Date())); let statisticHistory: { [key: string]: PlayerHistory } = {}; try { - const history = await ky + const { statistics: history } = await ky .get<{ - [key: string]: PlayerHistory; - }>(`${apiUrl}/api/player/history?id=${token.id}`) + statistics: { [key: string]: PlayerHistory }; + }>(`${apiUrl}/player/history/${token.id}${getPlayerIdCookie() == token.id ? "?createIfMissing=true" : ""}`) .json(); if (history === undefined || Object.entries(history).length === 0) { console.log("Player has no history, using fallback"); diff --git a/projects/common/src/types/player/player-tracked-since.ts b/projects/common/src/types/player/player-tracked-since.ts index 8b3756f..edb6a39 100644 --- a/projects/common/src/types/player/player-tracked-since.ts +++ b/projects/common/src/types/player/player-tracked-since.ts @@ -4,11 +4,6 @@ export interface PlayerTrackedSince { */ tracked: boolean; - /** - * The date the player was first tracked - */ - trackedSince?: string; - /** * The amount of days the player has been tracked */ diff --git a/projects/common/tsconfig.json b/projects/common/tsconfig.json index 30ac961..cf0405a 100644 --- a/projects/common/tsconfig.json +++ b/projects/common/tsconfig.json @@ -1,21 +1,18 @@ { "compilerOptions": { "module": "ES2022", - "moduleResolution": "Bundler", "target": "ES2022", - "declaration": true, - "removeComments": true, - "emitDecoratorMetadata": true, - "experimentalDecorators": true, - "allowSyntheticDefaultImports": true, - "sourceMap": true, "outDir": "./dist", - "baseUrl": "./", + "declaration": true, + "moduleResolution": "node", "skipLibCheck": true, - "strictNullChecks": false, - "noImplicitAny": false, - "strictBindCallApply": false, - "forceConsistentCasingInFileNames": false, - "noFallthroughCasesInSwitch": false - } + "allowSyntheticDefaultImports": true, + "strict": true, + "baseUrl": "./", + "paths": { + "@ssr/*": ["dist/*"] // This is crucial for resolving the imports correctly + } + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules"] } diff --git a/projects/common/tsup.config.ts b/projects/common/tsup.config.ts deleted file mode 100644 index dade4b1..0000000 --- a/projects/common/tsup.config.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { defineConfig } from "tsup"; - -export default defineConfig({ - entry: ["src/index.ts"], - splitting: false, - sourcemap: true, - clean: true, - dts: true, // Generates type declarations - format: ["esm"], // Ensures output is in ESM format -}); diff --git a/projects/website/.env-example b/projects/website/.env-example index f730995..b424e49 100644 --- a/projects/website/.env-example +++ b/projects/website/.env-example @@ -1,7 +1,2 @@ NEXT_PUBLIC_SITE_URL=http://localhost:3000 -NEXT_PUBLIC_TRIGGER_PUBLIC_API_KEY= - -TRIGGER_API_KEY= -TRIGGER_API_URL=https://trigger.example.com -MONGO_URI=mongodb://127.0.0.1:27017 -SENTRY_AUTH_TOKEN= +NEXT_PUBLIC_SITE_API=http://localhost:8080 \ No newline at end of file diff --git a/projects/website/Dockerfile b/projects/website/Dockerfile index 197be77..0ef35d0 100644 --- a/projects/website/Dockerfile +++ b/projects/website/Dockerfile @@ -1,25 +1,30 @@ -FROM node:20-alpine3.17 AS base +FROM oven/bun:1.1.30-alpine AS base -# Install pnpm -RUN npm install -g pnpm -ENV PNPM_HOME=/usr/local/bin +# Install dependencies +FROM base AS depends +WORKDIR /app +COPY . . +RUN bun install --frozen-lockfile +# Run the app FROM base AS runner WORKDIR /app -# Copy website package and lock files only -COPY package.json* pnpm-lock.yaml* pnpm-workspace.yaml* ./ -COPY website ./website - +# Set the environment +ENV NODE_ENV production ARG GIT_REV ENV GIT_REV=${GIT_REV} -RUN pnpm install --filter website -RUN pnpm run build:website +COPY --from=depends /app/node_modules ./node_modules +COPY --from=depends /app/package.json* /app/bun.lockb* ./ +COPY --from=depends /app/projects/website ./projects/website -# Expose the app port and start it +# Build the website +RUN bun run --filter website build + +# Expose the app port EXPOSE 3000 ENV HOSTNAME="0.0.0.0" ENV PORT=3000 -CMD ["pnpm", "start:website"] +CMD ["bun", "run", "--filter", "backend", "start"] diff --git a/projects/website/config.ts b/projects/website/config.ts index 5a2711a..b46cc87 100644 --- a/projects/website/config.ts +++ b/projects/website/config.ts @@ -1,3 +1,4 @@ export const config = { siteUrl: process.env.NEXT_PUBLIC_SITE_URL || "https://ssr.fascinated.cc", + siteApi: process.env.NEXT_PUBLIC_SITE_API || "https://ssr.fascinated.cc/api", }; diff --git a/projects/website/package.json b/projects/website/package.json index 024e3e1..e422d97 100644 --- a/projects/website/package.json +++ b/projects/website/package.json @@ -1,6 +1,6 @@ { "name": "website", - "version": "0.1.0", + "version": "1.0.0", "private": true, "scripts": { "dev": "next dev --turbo", @@ -22,9 +22,6 @@ "@radix-ui/react-tooltip": "^1.1.2", "@sentry/nextjs": "8", "@tanstack/react-query": "^5.55.4", - "@trigger.dev/nextjs": "^3.0.8", - "@trigger.dev/react": "^3.0.8", - "@trigger.dev/sdk": "^3.0.8", "@uidotdev/usehooks": "^2.4.1", "chart.js": "^4.4.4", "class-variance-authority": "^0.7.0", @@ -36,7 +33,6 @@ "js-cookie": "^3.0.5", "ky": "^1.7.2", "lucide-react": "^0.447.0", - "mongoose": "^8.7.0", "next": "15.0.0-rc.0", "next-build-id": "^3.0.0", "next-themes": "^0.3.0", diff --git a/projects/website/src/app/(pages)/api/player/history/route.ts b/projects/website/src/app/(pages)/api/player/history/route.ts deleted file mode 100644 index b7395da..0000000 --- a/projects/website/src/app/(pages)/api/player/history/route.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { connectMongo } from "@/common/mongo"; -import { IPlayer, PlayerModel } from "@/common/schema/player-schema"; -import { seedPlayerHistory } from "@/common/player-utils"; -import { scoresaberService } from "@/common/service/impl/scoresaber"; - -export async function GET(request: NextRequest) { - const playerIdCookie = request.cookies.get("playerId"); - const id = request.nextUrl.searchParams.get("id"); - if (id == null) { - return NextResponse.json({ error: "Unknown player. Missing: ?id=" }, { status: 400 }); - } - const shouldCreatePlayer = playerIdCookie?.value === id; - - await connectMongo(); // Connect to Mongo - - // Fetch the player and return their statistic history - let foundPlayer: IPlayer | null = await PlayerModel.findById(id); - if (shouldCreatePlayer && foundPlayer == null) { - foundPlayer = await PlayerModel.create({ - _id: id, - trackedSince: new Date().toISOString(), - }); - const response = await scoresaberService.lookupPlayer(id, true); - if (response != undefined) { - const { player, rawPlayer } = response; - await seedPlayerHistory(foundPlayer!, player, rawPlayer); - } - } - if (foundPlayer == null) { - return NextResponse.json({ error: "Player not found" }, { status: 404 }); - } - - return NextResponse.json(foundPlayer.getHistoryPrevious(50)); -} diff --git a/projects/website/src/app/(pages)/api/player/isbeingtracked/route.ts b/projects/website/src/app/(pages)/api/player/isbeingtracked/route.ts deleted file mode 100644 index d80bdd3..0000000 --- a/projects/website/src/app/(pages)/api/player/isbeingtracked/route.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { connectMongo } from "@/common/mongo"; -import { IPlayer, PlayerModel } from "@/common/schema/player-schema"; -import { PlayerTrackedSince } from "@/common/player/player-tracked-since"; - -export async function GET(request: NextRequest) { - const id = request.nextUrl.searchParams.get("id"); - if (id == null) { - return NextResponse.json({ error: "Unknown player. Missing: ?id=" }, { status: 400 }); - } - await connectMongo(); // Connect to Mongo - - const foundPlayer: IPlayer | null = await PlayerModel.findById(id); - const response: PlayerTrackedSince = { - tracked: foundPlayer != null, - }; - if (foundPlayer != null) { - response["trackedSince"] = foundPlayer.trackedSince?.toUTCString(); - response["daysTracked"] = foundPlayer.getStatisticHistory().size; - } - return NextResponse.json(response); -} diff --git a/projects/website/src/app/(pages)/api/proxy/route.ts b/projects/website/src/app/(pages)/api/proxy/route.ts deleted file mode 100644 index edbec74..0000000 --- a/projects/website/src/app/(pages)/api/proxy/route.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { validateUrl } from "@/common/utils"; -import ky from "ky"; -import { NextRequest, NextResponse } from "next/server"; - -export async function GET(request: NextRequest) { - const url = request.nextUrl.searchParams.get("url"); - if (url == null) { - return NextResponse.json({ error: "Missing URL. ?url=" }, { status: 400 }); - } - - if (!validateUrl(url)) { - return NextResponse.json({ error: "Invalid URL" }, { status: 400 }); - } - - try { - const response = await ky.get(url, { - next: { - revalidate: 30, // 30 seconds - }, - }); - const { status, headers } = response; - if ( - !headers.has("content-type") || - (headers.has("content-type") && !headers.get("content-type")?.includes("application/json")) - ) { - return NextResponse.json({ - error: "We only support proxying JSON responses", - }); - } - - const body = await response.json(); - return NextResponse.json(body, { - status: status, - }); - } catch (err) { - console.error(`Error fetching data from ${url}:`, err); - return NextResponse.json( - { error: "Failed to proxy this request." }, - { - status: 500, - headers: { - "Access-Control-Allow-Origin": "*", - }, - } - ); - } -} diff --git a/projects/website/src/app/(pages)/api/trigger/route.ts b/projects/website/src/app/(pages)/api/trigger/route.ts deleted file mode 100644 index 6331ed9..0000000 --- a/projects/website/src/app/(pages)/api/trigger/route.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { createAppRoute } from "@trigger.dev/nextjs"; -import { client } from "@/trigger"; - -import "@/jobs"; - -//this route is used to send and receive data with Trigger.dev -export const { POST, dynamic } = createAppRoute(client); - -//uncomment this to set a higher max duration (it must be inside your plan limits). Full docs: https://vercel.com/docs/functions/serverless-functions/runtimes#max-duration -//export const maxDuration = 60; diff --git a/projects/website/src/app/(pages)/player/[...slug]/page.tsx b/projects/website/src/app/(pages)/player/[...slug]/page.tsx index 3781990..b9d9ba4 100644 --- a/projects/website/src/app/(pages)/player/[...slug]/page.tsx +++ b/projects/website/src/app/(pages)/player/[...slug]/page.tsx @@ -1,14 +1,16 @@ import { formatNumberWithCommas, formatPp } from "@/common/number-utils"; -import { scoresaberService } from "@/common/service/impl/scoresaber"; -import { ScoreSort } from "@/common/model/score/score-sort"; import PlayerData from "@/components/player/player-data"; import { format } from "@formkit/tempo"; import { Metadata, Viewport } from "next"; import { redirect } from "next/navigation"; import { Colors } from "@/common/colors"; -import ScoreSaberPlayerScoresPageToken from "@/common/model/token/scoresaber/score-saber-player-scores-page-token"; import { getAverageColor } from "@/common/image-utils"; import { cache } from "react"; +import { ScoreSort } from "@ssr/common/types/score/score-sort"; +import { scoresaberService } from "@ssr/common/service/impl/scoresaber"; +import ScoreSaberPlayerScoresPageToken from "@ssr/common/types/token/scoresaber/score-saber-player-scores-page-token"; +import { getScoreSaberPlayerFromToken } from "@ssr/common/types/player/impl/scoresaber-player"; +import { config } from "../../../../../config"; const UNKNOWN_PLAYER = { title: "ScoreSaber Reloaded - Unknown Player", @@ -38,7 +40,8 @@ const getPlayerData = cache(async ({ params }: Props, fetchScores: boolean = tru const page = parseInt(slug[2]) || 1; // The page number const search = (slug[3] as string) || ""; // The search query - const player = (await scoresaberService.lookupPlayer(id, false))?.player; + const playerToken = await scoresaberService.lookupPlayer(id); + const player = playerToken && (await getScoreSaberPlayerFromToken(playerToken, config.siteApi)); let scores: ScoreSaberPlayerScoresPageToken | undefined; if (fetchScores) { scores = await scoresaberService.lookupPlayerScores({ diff --git a/projects/website/src/common/database/types/beatsaver-map.ts b/projects/website/src/common/database/types/beatsaver-map.ts new file mode 100644 index 0000000..31a33e4 --- /dev/null +++ b/projects/website/src/common/database/types/beatsaver-map.ts @@ -0,0 +1,23 @@ +import { Entity } from "dexie"; +import Database from "../database"; +import { BeatSaverMapToken } from "@ssr/common/types/token/beatsaver/beat-saver-map-token"; + +/** + * A beat saver map. + */ +export default class BeatSaverMap extends Entity { + /** + * The hash of the map. + */ + hash!: string; + + /** + * The bsr code for the map. + */ + bsr!: string; + + /** + * The full data for the map. + */ + fullData!: BeatSaverMapToken; +} diff --git a/projects/website/src/common/mongo.ts b/projects/website/src/common/mongo.ts deleted file mode 100644 index 344a596..0000000 --- a/projects/website/src/common/mongo.ts +++ /dev/null @@ -1,12 +0,0 @@ -import * as mongoose from "mongoose"; - -/** - * Connects to the mongo database - */ -export async function connectMongo() { - const connectionUri = process.env.MONGO_URI; - if (!connectionUri) { - throw new Error("Missing MONGO_URI"); - } - await mongoose.connect(connectionUri); -} diff --git a/projects/website/src/common/player-utils.ts b/projects/website/src/common/player-utils.ts index fcfe265..2335694 100644 --- a/projects/website/src/common/player-utils.ts +++ b/projects/website/src/common/player-utils.ts @@ -1,4 +1,4 @@ -import { PlayerHistory } from "@/common/player/player-history"; +import { PlayerHistory } from "@ssr/common/types/player/player-history"; /** * Gets a value from an {@link PlayerHistory} diff --git a/projects/website/src/common/website-utils.ts b/projects/website/src/common/website-utils.ts index 54020b6..c2ffdf8 100644 --- a/projects/website/src/common/website-utils.ts +++ b/projects/website/src/common/website-utils.ts @@ -9,6 +9,15 @@ export function setPlayerIdCookie(playerId: string) { Cookies.set("playerId", playerId, { path: "/" }); } +/** + * Gets the player id cookie + * + * @returns the player id cookie + */ +export function getPlayerIdCookie() { + return Cookies.get("playerId"); +} + /** * Gets if we're in production */ diff --git a/projects/website/src/common/worker/worker.ts b/projects/website/src/common/worker/worker.ts index 2d8bd4f..05abb9f 100644 --- a/projects/website/src/common/worker/worker.ts +++ b/projects/website/src/common/worker/worker.ts @@ -1,5 +1,5 @@ import * as Comlink from "comlink"; -import { scoresaberService } from "@/common/service/impl/scoresaber"; +import { scoresaberService } from "@ssr/common/service/impl/scoresaber"; export interface WorkerApi { getPlayerExample: typeof getPlayerExample; diff --git a/projects/website/src/components/input/search-player.tsx b/projects/website/src/components/input/search-player.tsx index 8df7d76..34016f9 100644 --- a/projects/website/src/components/input/search-player.tsx +++ b/projects/website/src/components/input/search-player.tsx @@ -1,7 +1,5 @@ "use client"; -import { scoresaberService } from "@/common/service/impl/scoresaber"; -import ScoreSaberPlayerToken from "@/common/model/token/scoresaber/score-saber-player-token"; import { formatNumberWithCommas } from "@/common/number-utils"; import { zodResolver } from "@hookform/resolvers/zod"; import Link from "next/link"; @@ -13,6 +11,8 @@ import { Button } from "../ui/button"; import { Form, FormControl, FormField, FormItem, FormLabel } from "../ui/form"; import { Input } from "../ui/input"; import { ScrollArea } from "../ui/scroll-area"; +import ScoreSaberPlayerToken from "@ssr/common/types/token/scoresaber/score-saber-player-token"; +import { scoresaberService } from "@ssr/common/service/impl/scoresaber"; const formSchema = z.object({ username: z.string().min(3).max(50), diff --git a/projects/website/src/components/leaderboard/leaderboard-data.tsx b/projects/website/src/components/leaderboard/leaderboard-data.tsx index 8c8ed2f..921cd5c 100644 --- a/projects/website/src/components/leaderboard/leaderboard-data.tsx +++ b/projects/website/src/components/leaderboard/leaderboard-data.tsx @@ -1,14 +1,13 @@ "use client"; -import ScoreSaberLeaderboardScoresPageToken from "@/common/model/token/scoresaber/score-saber-leaderboard-scores-page-token"; -import ScoreSaberLeaderboardToken from "@/common/model/token/scoresaber/score-saber-leaderboard-token"; import LeaderboardScores from "@/components/leaderboard/leaderboard-scores"; import { LeaderboardInfo } from "@/components/leaderboard/leaderboard-info"; import { useQuery } from "@tanstack/react-query"; -import { scoresaberService } from "@/common/service/impl/scoresaber"; -import { useCallback, useEffect, useState } from "react"; +import { useEffect, useState } from "react"; import BeatSaverMap from "@/common/database/types/beatsaver-map"; -import { beatsaverService } from "@/common/service/impl/beatsaver"; +import ScoreSaberLeaderboardScoresPageToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-scores-page-token"; +import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token"; +import { scoresaberService } from "@ssr/common/service/impl/scoresaber"; type LeaderboardDataProps = { /** @@ -39,14 +38,15 @@ export function LeaderboardData({ initialPage, initialScores, initialLeaderboard staleTime: 30 * 1000, // Cache data for 30 seconds }); - const fetchBeatSaverData = useCallback(async () => { - const beatSaverMap = await beatsaverService.lookupMap(initialLeaderboard.songHash); - setBeatSaverMap(beatSaverMap); - }, [initialLeaderboard.songHash]); - - useEffect(() => { - fetchBeatSaverData(); - }, [fetchBeatSaverData]); + // todo: fix + // const fetchBeatSaverData = useCallback(async () => { + // const beatSaverMap = await beatsaverService.lookupMap(initialLeaderboard.songHash); + // setBeatSaverMap(beatSaverMap); + // }, [initialLeaderboard.songHash]); + // + // useEffect(() => { + // fetchBeatSaverData(); + // }, [fetchBeatSaverData]); /** * When the leaderboard changes, update the previous and current leaderboards. diff --git a/projects/website/src/components/leaderboard/leaderboard-info.tsx b/projects/website/src/components/leaderboard/leaderboard-info.tsx index 70c188a..2b679ae 100644 --- a/projects/website/src/components/leaderboard/leaderboard-info.tsx +++ b/projects/website/src/components/leaderboard/leaderboard-info.tsx @@ -1,9 +1,9 @@ import Card from "@/components/card"; -import ScoreSaberLeaderboardToken from "@/common/model/token/scoresaber/score-saber-leaderboard-token"; import Image from "next/image"; import { LeaderboardSongStarCount } from "@/components/leaderboard/leaderboard-song-star-count"; import ScoreButtons from "@/components/score/score-buttons"; import BeatSaverMap from "@/common/database/types/beatsaver-map"; +import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token"; type LeaderboardInfoProps = { /** diff --git a/projects/website/src/components/leaderboard/leaderboard-player.tsx b/projects/website/src/components/leaderboard/leaderboard-player.tsx index 07816fe..61d4362 100644 --- a/projects/website/src/components/leaderboard/leaderboard-player.tsx +++ b/projects/website/src/components/leaderboard/leaderboard-player.tsx @@ -1,7 +1,7 @@ -import ScoreSaberPlayer from "@/common/model/player/impl/scoresaber-player"; -import ScoreSaberScoreToken from "@/common/model/token/scoresaber/score-saber-score-token"; import Image from "next/image"; import Link from "next/link"; +import ScoreSaberScoreToken from "@ssr/common/types/token/scoresaber/score-saber-score-token"; +import ScoreSaberPlayer from "@ssr/common/types/player/impl/scoresaber-player"; type Props = { /** diff --git a/projects/website/src/components/leaderboard/leaderboard-score-stats.tsx b/projects/website/src/components/leaderboard/leaderboard-score-stats.tsx index a60e99c..70751c2 100644 --- a/projects/website/src/components/leaderboard/leaderboard-score-stats.tsx +++ b/projects/website/src/components/leaderboard/leaderboard-score-stats.tsx @@ -1,11 +1,11 @@ -import ScoreSaberLeaderboardToken from "@/common/model/token/scoresaber/score-saber-leaderboard-token"; -import ScoreSaberScoreToken from "@/common/model/token/scoresaber/score-saber-score-token"; import { formatNumberWithCommas } from "@/common/number-utils"; import { XMarkIcon } from "@heroicons/react/24/solid"; import clsx from "clsx"; import { getScoreBadgeFromAccuracy } from "@/common/song-utils"; import Tooltip from "@/components/tooltip"; import { ScoreBadge, ScoreBadges } from "@/components/score/score-badge"; +import ScoreSaberScoreToken from "@ssr/common/types/token/scoresaber/score-saber-score-token"; +import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token"; const badges: ScoreBadge[] = [ { diff --git a/projects/website/src/components/leaderboard/leaderboard-score.tsx b/projects/website/src/components/leaderboard/leaderboard-score.tsx index 36aa4a3..bda3e31 100644 --- a/projects/website/src/components/leaderboard/leaderboard-score.tsx +++ b/projects/website/src/components/leaderboard/leaderboard-score.tsx @@ -1,9 +1,9 @@ -import ScoreSaberLeaderboardToken from "@/common/model/token/scoresaber/score-saber-leaderboard-token"; -import ScoreSaberScoreToken from "@/common/model/token/scoresaber/score-saber-score-token"; import LeaderboardPlayer from "./leaderboard-player"; import LeaderboardScoreStats from "./leaderboard-score-stats"; import ScoreRankInfo from "@/components/score/score-rank-info"; -import ScoreSaberPlayer from "@/common/model/player/impl/scoresaber-player"; +import ScoreSaberPlayer from "@ssr/common/types/player/impl/scoresaber-player"; +import ScoreSaberScoreToken from "@ssr/common/types/token/scoresaber/score-saber-score-token"; +import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token"; type Props = { /** diff --git a/projects/website/src/components/leaderboard/leaderboard-scores.tsx b/projects/website/src/components/leaderboard/leaderboard-scores.tsx index bbe21b7..b16df40 100644 --- a/projects/website/src/components/leaderboard/leaderboard-scores.tsx +++ b/projects/website/src/components/leaderboard/leaderboard-scores.tsx @@ -1,8 +1,5 @@ "use client"; -import { scoresaberService } from "@/common/service/impl/scoresaber"; -import ScoreSaberLeaderboardToken from "@/common/model/token/scoresaber/score-saber-leaderboard-token"; -import ScoreSaberLeaderboardScoresPageToken from "@/common/model/token/scoresaber/score-saber-leaderboard-scores-page-token"; import useWindowDimensions from "@/hooks/use-window-dimensions"; import { useQuery } from "@tanstack/react-query"; import { motion, useAnimation } from "framer-motion"; @@ -11,10 +8,13 @@ import Card from "../card"; import Pagination from "../input/pagination"; import LeaderboardScore from "./leaderboard-score"; import { scoreAnimation } from "@/components/score/score-animation"; -import ScoreSaberPlayer from "@/common/model/player/impl/scoresaber-player"; import { Button } from "@/components/ui/button"; import { clsx } from "clsx"; import { getDifficultyFromRawDifficulty } from "@/common/song-utils"; +import ScoreSaberLeaderboardScoresPageToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-scores-page-token"; +import ScoreSaberPlayer from "@ssr/common/types/player/impl/scoresaber-player"; +import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token"; +import { scoresaberService } from "@ssr/common/service/impl/scoresaber"; type LeaderboardScoresProps = { /** diff --git a/projects/website/src/components/leaderboard/leaderboard-song-star-count.tsx b/projects/website/src/components/leaderboard/leaderboard-song-star-count.tsx index 7c67564..c27b375 100644 --- a/projects/website/src/components/leaderboard/leaderboard-song-star-count.tsx +++ b/projects/website/src/components/leaderboard/leaderboard-song-star-count.tsx @@ -1,7 +1,7 @@ import { songDifficultyToColor } from "@/common/song-utils"; import { StarIcon } from "@heroicons/react/24/solid"; -import ScoreSaberLeaderboardToken from "@/common/model/token/scoresaber/score-saber-leaderboard-token"; import { getDifficultyFromScoreSaberDifficulty } from "@/common/scoresaber-utils"; +import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token"; type LeaderboardSongStarCountProps = { /** diff --git a/projects/website/src/components/player/chart/generic-player-chart.tsx b/projects/website/src/components/player/chart/generic-player-chart.tsx index 21476b0..f262b5e 100644 --- a/projects/website/src/components/player/chart/generic-player-chart.tsx +++ b/projects/website/src/components/player/chart/generic-player-chart.tsx @@ -1,10 +1,10 @@ "use client"; -import { parseDate } from "@/common/time-utils"; -import ScoreSaberPlayer from "@/common/model/player/impl/scoresaber-player"; import React from "react"; import GenericChart, { DatasetConfig } from "@/components/chart/generic-chart"; import { getValueFromHistory } from "@/common/player-utils"; +import ScoreSaberPlayer from "@ssr/common/types/player/impl/scoresaber-player"; +import { parseDate } from "@ssr/common/utils/time-utils"; type Props = { /** diff --git a/projects/website/src/components/player/chart/player-accuracy-chart.tsx b/projects/website/src/components/player/chart/player-accuracy-chart.tsx index 88c88a2..1e032ec 100644 --- a/projects/website/src/components/player/chart/player-accuracy-chart.tsx +++ b/projects/website/src/components/player/chart/player-accuracy-chart.tsx @@ -1,9 +1,9 @@ "use client"; -import ScoreSaberPlayer from "@/common/model/player/impl/scoresaber-player"; import React from "react"; import { DatasetConfig } from "@/components/chart/generic-chart"; import GenericPlayerChart from "@/components/player/chart/generic-player-chart"; +import ScoreSaberPlayer from "@ssr/common/types/player/impl/scoresaber-player"; type Props = { player: ScoreSaberPlayer; diff --git a/projects/website/src/components/player/chart/player-charts.tsx b/projects/website/src/components/player/chart/player-charts.tsx index 4e9d575..2a4388f 100644 --- a/projects/website/src/components/player/chart/player-charts.tsx +++ b/projects/website/src/components/player/chart/player-charts.tsx @@ -1,12 +1,12 @@ "use client"; -import ScoreSaberPlayer from "@/common/model/player/impl/scoresaber-player"; import PlayerRankingChart from "@/components/player/chart/player-ranking-chart"; import { FC, useState } from "react"; import Tooltip from "@/components/tooltip"; import PlayerAccuracyChart from "@/components/player/chart/player-accuracy-chart"; import { GlobeAmericasIcon } from "@heroicons/react/24/solid"; import { TrendingUpIcon } from "lucide-react"; +import ScoreSaberPlayer from "@ssr/common/types/player/impl/scoresaber-player"; type PlayerChartsProps = { /** diff --git a/projects/website/src/components/player/chart/player-ranking-chart.tsx b/projects/website/src/components/player/chart/player-ranking-chart.tsx index ccdf18d..c70733b 100644 --- a/projects/website/src/components/player/chart/player-ranking-chart.tsx +++ b/projects/website/src/components/player/chart/player-ranking-chart.tsx @@ -1,10 +1,10 @@ "use client"; import { formatNumberWithCommas } from "@/common/number-utils"; -import ScoreSaberPlayer from "@/common/model/player/impl/scoresaber-player"; import React from "react"; import { DatasetConfig } from "@/components/chart/generic-chart"; import GenericPlayerChart from "@/components/player/chart/generic-player-chart"; +import ScoreSaberPlayer from "@ssr/common/types/player/impl/scoresaber-player"; type Props = { player: ScoreSaberPlayer; diff --git a/projects/website/src/components/player/player-badges.tsx b/projects/website/src/components/player/player-badges.tsx index 9522308..7ebed33 100644 --- a/projects/website/src/components/player/player-badges.tsx +++ b/projects/website/src/components/player/player-badges.tsx @@ -1,6 +1,6 @@ -import ScoreSaberPlayer from "@/common/model/player/impl/scoresaber-player"; import Image from "next/image"; import Tooltip from "@/components/tooltip"; +import ScoreSaberPlayer from "@ssr/common/types/player/impl/scoresaber-player"; type Props = { player: ScoreSaberPlayer; diff --git a/projects/website/src/components/player/player-data.tsx b/projects/website/src/components/player/player-data.tsx index 27aff4f..9b6a0eb 100644 --- a/projects/website/src/components/player/player-data.tsx +++ b/projects/website/src/components/player/player-data.tsx @@ -1,19 +1,20 @@ "use client"; -import ScoreSaberPlayerScoresPageToken from "@/common/model/token/scoresaber/score-saber-player-scores-page-token"; -import { scoresaberService } from "@/common/service/impl/scoresaber"; -import { ScoreSort } from "@/common/model/score/score-sort"; import { useQuery } from "@tanstack/react-query"; import Mini from "../ranking/mini"; import PlayerHeader from "./player-header"; import PlayerScores from "./player-scores"; -import ScoreSaberPlayer from "@/common/model/player/impl/scoresaber-player"; import Card from "@/components/card"; import PlayerBadges from "@/components/player/player-badges"; import { useIsMobile } from "@/hooks/use-is-mobile"; import { useIsVisible } from "@/hooks/use-is-visible"; import { useRef } from "react"; import PlayerCharts from "@/components/player/chart/player-charts"; +import ScoreSaberPlayer, { getScoreSaberPlayerFromToken } from "@ssr/common/types/player/impl/scoresaber-player"; +import ScoreSaberPlayerScoresPageToken from "@ssr/common/types/token/scoresaber/score-saber-player-scores-page-token"; +import { ScoreSort } from "@ssr/common/types/score/score-sort"; +import { scoresaberService } from "@ssr/common/service/impl/scoresaber"; +import { config } from "../../../config"; type Props = { initialPlayerData: ScoreSaberPlayer; @@ -37,12 +38,18 @@ export default function PlayerData({ let player = initialPlayerData; const { data, isLoading, isError } = useQuery({ queryKey: ["player", player.id], - queryFn: () => scoresaberService.lookupPlayer(player.id), + queryFn: async (): Promise => { + const playerResponse = await scoresaberService.lookupPlayer(player.id); + if (playerResponse == undefined) { + return undefined; + } + return await getScoreSaberPlayerFromToken(playerResponse, config.siteApi); + }, staleTime: 1000 * 60 * 5, // Cache data for 5 minutes }); if (data && (!isLoading || !isError)) { - player = data.player; + player = data; } return ( diff --git a/projects/website/src/components/player/player-scores.tsx b/projects/website/src/components/player/player-scores.tsx index ddb76ae..3ad43bf 100644 --- a/projects/website/src/components/player/player-scores.tsx +++ b/projects/website/src/components/player/player-scores.tsx @@ -7,15 +7,15 @@ import { useCallback, useEffect, useRef, useState } from "react"; import Card from "../card"; import Pagination from "../input/pagination"; import { Button } from "../ui/button"; -import { ScoreSort } from "@/common/model/score/score-sort"; -import ScoreSaberPlayerScoresPageToken from "@/common/model/token/scoresaber/score-saber-player-scores-page-token"; import Score from "@/components/score/score"; -import ScoreSaberPlayer from "@/common/model/player/impl/scoresaber-player"; -import { scoresaberService } from "@/common/service/impl/scoresaber"; import { Input } from "@/components/ui/input"; import { clsx } from "clsx"; import { useDebounce } from "@uidotdev/usehooks"; import { scoreAnimation } from "@/components/score/score-animation"; +import ScoreSaberPlayer from "@ssr/common/types/player/impl/scoresaber-player"; +import ScoreSaberPlayerScoresPageToken from "@ssr/common/types/token/scoresaber/score-saber-player-scores-page-token"; +import { ScoreSort } from "@ssr/common/types/score/score-sort"; +import { scoresaberService } from "@ssr/common/service/impl/scoresaber"; type Props = { initialScoreData?: ScoreSaberPlayerScoresPageToken; diff --git a/projects/website/src/components/player/player-stats.tsx b/projects/website/src/components/player/player-stats.tsx index 553f778..0cafe7f 100644 --- a/projects/website/src/components/player/player-stats.tsx +++ b/projects/website/src/components/player/player-stats.tsx @@ -1,6 +1,6 @@ import { formatNumberWithCommas } from "@/common/number-utils"; import StatValue from "@/components/stat-value"; -import ScoreSaberPlayer from "@/common/model/player/impl/scoresaber-player"; +import ScoreSaberPlayer from "@ssr/common/types/player/impl/scoresaber-player"; type Badge = { name: string; diff --git a/projects/website/src/components/player/player-tracked-status.tsx b/projects/website/src/components/player/player-tracked-status.tsx index c21b16c..3621fa8 100644 --- a/projects/website/src/components/player/player-tracked-status.tsx +++ b/projects/website/src/components/player/player-tracked-status.tsx @@ -1,15 +1,13 @@ "use client"; import { useQuery } from "@tanstack/react-query"; -import ScoreSaberPlayer from "@/common/model/player/impl/scoresaber-player"; import ky from "ky"; import { config } from "../../../config"; import Tooltip from "@/components/tooltip"; import { InformationCircleIcon } from "@heroicons/react/16/solid"; -import { format } from "@formkit/tempo"; -import { PlayerTrackedSince } from "@/common/player/player-tracked-since"; -import { getDaysAgo } from "@/common/time-utils"; import { formatNumberWithCommas } from "@/common/number-utils"; +import { PlayerTrackedSince } from "@ssr/common/types/player/player-tracked-since"; +import ScoreSaberPlayer from "@ssr/common/types/player/impl/scoresaber-player"; type Props = { player: ScoreSaberPlayer; @@ -18,32 +16,19 @@ type Props = { export default function PlayerTrackedStatus({ player }: Props) { const { data, isLoading, isError } = useQuery({ queryKey: ["playerIsBeingTracked", player.id], - queryFn: () => ky.get(`${config.siteUrl}/api/player/isbeingtracked?id=${player.id}`).json(), + queryFn: () => ky.get(`${config.siteApi}/player/tracked/${player.id}`).json(), }); if (isLoading || isError || !data?.tracked) { return undefined; } - const trackedSince = new Date(data.trackedSince!); - const daysAgo = getDaysAgo(trackedSince) + 1; - let daysAgoFormatted = `${formatNumberWithCommas(daysAgo)} day${daysAgo > 1 ? "s" : ""} ago`; - if (daysAgo === 1) { - daysAgoFormatted = "Today"; - } - if (daysAgo === 2) { - daysAgoFormatted = "Yesterday"; - } - return (