start backend work

This commit is contained in:
Lee
2024-10-08 15:32:02 +01:00
parent 04ce91b459
commit aa0a0c4c16
445 changed files with 367 additions and 11413 deletions
.gitea
backend
bun.lockb
common
package.jsonpnpm-lock.yamlpnpm-workspace.yaml
projects
backend
common
website
.dockerignore.env-example.eslintrc.json.gitignoreDockerfilecomponents.jsonconfig.tsnext.config.mjspackage.jsonpostcss.config.mjs
public
assets
background.jpg
flags
ad.pngae.pngaf.pngag.pngai.pngal.pngam.pngao.pngaq.pngar.pngas.pngat.pngau.pngaw.pngax.pngaz.pngba.pngbb.pngbd.pngbe.pngbf.pngbg.pngbh.pngbi.pngbj.pngbl.pngbm.pngbn.pngbo.pngbq.pngbr.pngbs.pngbt.pngbv.pngbw.pngby.pngbz.pngca.pngcc.pngcd.pngcf.pngcg.pngch.pngci.pngck.pngcl.pngcm.pngcn.pngco.pngcr.pngcu.pngcv.pngcw.pngcx.pngcy.pngcz.pngde.pngdj.pngdk.pngdm.pngdo.pngdz.pngec.pngee.pngeg.pngeh.pnger.pnges.pnget.pngfi.pngfj.pngfk.pngfm.pngfo.pngfr.pngga.pnggb-eng.pnggb-nir.pnggb-sct.pnggb-wls.pnggb.pnggd.pngge.pnggf.pnggg.pnggh.pnggi.pnggl.pnggm.pnggn.pnggp.pnggq.pnggr.pnggs.pnggt.pnggu.pnggw.pnggy.pnghk.pnghm.pnghn.pnghr.pnght.pnghu.pngid.pngie.pngil.pngim.pngin.pngio.pngiq.pngir.pngis.pngit.pngje.pngjm.pngjo.pngjp.pngke.pngkg.pngkh.pngki.pngkm.pngkn.pngkp.pngkr.pngkw.pngky.pngkz.pngla.pnglb.pnglc.pngli.pnglk.pnglr.pngls.pnglt.pnglu.pnglv.pngly.pngma.pngmc.pngmd.pngme.pngmf.pngmg.pngmh.pngmk.pngml.pngmm.pngmn.pngmo.pngmp.pngmq.pngmr.pngms.pngmt.pngmu.pngmv.pngmw.pngmx.pngmy.pngmz.pngna.pngnc.pngne.pngnf.pngng.pngni.pngnl.pngno.pngnot set.pngnp.pngnr.pngnu.pngnz.pngom.pngpa.pngpe.pngpf.pngpg.pngph.pngpk.pngpl.pngpm.pngpn.pngpr.pngps.pngpt.pngpw.pngpy.pngqa.pngre.pngro.pngrs.pngru.pngrw.pngsa.pngsb.pngsc.pngsd.pngse.pngsg.pngsh.pngsi.pngsj.pngsk.pngsl.pngsm.pngsn.pngso.pngsr.pngss.pngst.pngsv.pngsx.pngsy.pngsz.pngtc.pngtd.pngtf.pngtg.pngth.pngtj.pngtk.pngtl.pngtm.pngtn.pngto.pngtr.pngtt.pngtv.pngtw.pngtz.pngua.pngug.pngum.pngus.pnguy.pnguz.pngva.pngvc.pngve.pngvg.pngvi.pngvn.pngvu.pngwf.pngws.pngxk.pngye.pngyt.pngza.pngzm.pngzw.png
logos
favicon.ico
sentry.client.config.tssentry.edge.config.tssentry.server.config.ts
src
app
(pages)
api
player
history
isbeingtracked
proxy
trigger
leaderboard/[...slug]
page.tsx
player/[...slug]
search
settings
components/ui
fonts
global-error.tsxglobals.csslayout.tsx
common
components
hooks
instrumentation.ts
jobs
trigger.ts
tailwind.config.tstsconfig.json
website/src/common

@ -0,0 +1,2 @@
node_modules
dist

42
projects/backend/.gitignore vendored Normal file

@ -0,0 +1,42 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env.local
.env.development.local
.env.test.local
.env.production.local
# vercel
.vercel
**/*.trace
**/*.zip
**/*.tar.gz
**/*.tgz
**/*.log
package-lock.json
**/*.bun

@ -0,0 +1,19 @@
FROM imbios/bun-node AS base
# Install dependencies
FROM base AS depends
WORKDIR /app
COPY . .
RUN bun install --frozen-lockfile
# Run the app
FROM base AS runner
WORKDIR /app
ENV NODE_ENV production
COPY --from=depends /app/node_modules ./node_modules
COPY --from=depends /app/package.json* /app/bun.lockb* ./
COPY --from=depends /app/projects/backend ./projects/backend
CMD ["bun", "run", "--filter", "backend", "start"]

@ -0,0 +1,9 @@
# Backend
## Development
To start the development server run:
```bash
bun run dev
```
Open http://localhost:3000/ with your browser to see the result.

@ -0,0 +1,21 @@
{
"name": "backend",
"version": "1.0.0",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"dev": "bun run --watch src/index.ts",
"start": "bun run src/index.ts"
},
"dependencies": {
"@elysiajs/cors": "^1.1.1",
"@ssr/common": "workspace:common",
"@tqman/nice-logger": "^1.0.1",
"elysia": "latest",
"elysia-autoroutes": "^0.5.0",
"elysia-decorators": "^1.0.2"
},
"devDependencies": {
"bun-types": "latest"
},
"module": "src/index.js"
}

@ -0,0 +1,10 @@
/**
* Gets the app version.
*/
export function getAppVersion() {
if (!process.env.APP_VERSION) {
const packageJson = require("../../package.json");
process.env.APP_VERSION = packageJson.version;
}
return process.env.APP_VERSION + "-" + (process.env.GIT_REV?.substring(0, 7) ?? "dev");
}

@ -0,0 +1,13 @@
import { Controller, Get } from "elysia-decorators";
import { getAppVersion } from "../common/app-utils";
@Controller("/")
export default class AppController {
@Get()
public index() {
return {
app: "backend",
version: getAppVersion(),
};
}
}

@ -0,0 +1,53 @@
import { Elysia } from "elysia";
import cors from "@elysiajs/cors";
import { decorators } from "elysia-decorators";
import { logger } from "@tqman/nice-logger";
import AppController from "./controller/app";
const app = new Elysia();
/**
* Custom error handler
*/
app.onError({ as: "global" }, ({ code, error }) => {
// Return default error for type validation
if (code === "VALIDATION") {
return error.all;
}
let status = "status" in error ? error.status : undefined;
return {
...((status && { statusCode: status }) || { status: code }),
...(error.message != code && { message: error.message }),
timestamp: new Date().toISOString(),
};
});
/**
* Enable CORS
*/
app.use(cors());
/**
* Request logger
*/
app.use(
logger({
mode: "combined",
})
);
/**
* Controllers
*/
app.use(
decorators({
controllers: [AppController],
})
);
app.onStart(() => {
console.log("Listening on port http://localhost:8080");
});
app.listen(8080);

@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2021",
"module": "ES2022",
"moduleResolution": "node",
"types": ["bun-types"],
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true
}
}

@ -0,0 +1,2 @@
node_modules
dist

@ -0,0 +1,17 @@
{
"name": "@ssr/common",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "tsup src/index.ts --watch",
"build": "tsup src/index.ts"
},
"devDependencies": {
"@types/node": "^22.7.4",
"tsup": "^8",
"typescript": "^5"
},
"dependencies": {
"ky": "^1.7.2"
}
}

@ -0,0 +1,49 @@
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";

@ -0,0 +1,34 @@
import Service from "../service";
import { BeatSaverMapToken } from "../../types/token/beatsaver/beat-saver-map-token";
const API_BASE = "https://api.beatsaver.com";
const LOOKUP_MAP_BY_HASH_ENDPOINT = `${API_BASE}/maps/hash/:query`;
class BeatSaverService extends Service {
constructor() {
super("BeatSaver");
}
/**
* Gets the map that match the query.
*
* @param query the query to search for
* @param useProxy whether to use the proxy or not
* @returns the map that match the query, or undefined if no map were found
*/
async lookupMap(query: string): Promise<BeatSaverMapToken | undefined> {
const before = performance.now();
this.log(`Looking up map "${query}"...`);
const response = await this.fetch<BeatSaverMapToken>(LOOKUP_MAP_BY_HASH_ENDPOINT.replace(":query", query));
// Map not found
if (response == undefined) {
return undefined;
}
this.log(`Found map "${response.id}" in ${(performance.now() - before).toFixed(0)}ms`);
return response;
}
}
export const beatsaverService = new BeatSaverService();

@ -0,0 +1,207 @@
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";
import ScoreSaberLeaderboardToken from "../../types/token/scoresaber/score-saber-leaderboard-token";
import ScoreSaberLeaderboardScoresPageToken from "../../types/token/scoresaber/score-saber-leaderboard-scores-page-token";
const API_BASE = "https://scoresaber.com/api";
/**
* Player
*/
const SEARCH_PLAYERS_ENDPOINT = `${API_BASE}/players?search=:query`;
const LOOKUP_PLAYER_ENDPOINT = `${API_BASE}/player/:id/full`;
const LOOKUP_PLAYERS_ENDPOINT = `${API_BASE}/players?page=:page`;
const LOOKUP_PLAYERS_BY_COUNTRY_ENDPOINT = `${API_BASE}/players?page=:page&countries=:country`;
const LOOKUP_PLAYER_SCORES_ENDPOINT = `${API_BASE}/player/:id/scores?limit=:limit&sort=:sort&page=:page`;
/**
* Leaderboard
*/
const LOOKUP_LEADERBOARD_ENDPOINT = `${API_BASE}/leaderboard/by-id/:id/info`;
const LOOKUP_LEADERBOARD_SCORES_ENDPOINT = `${API_BASE}/leaderboard/by-id/:id/scores?page=:page`;
class ScoreSaberService extends Service {
constructor() {
super("ScoreSaber");
}
/**
* Gets the players that match the query.
*
* @param query the query to search for
* @returns the players that match the query, or undefined if no players were found
*/
async searchPlayers(query: string): Promise<ScoreSaberPlayerSearchToken | undefined> {
const before = performance.now();
this.log(`Searching for players matching "${query}"...`);
const results = await this.fetch<ScoreSaberPlayerSearchToken>(SEARCH_PLAYERS_ENDPOINT.replace(":query", query));
if (results === undefined) {
return undefined;
}
if (results.players.length === 0) {
return undefined;
}
results.players.sort((a, b) => a.rank - b.rank);
this.log(`Found ${results.players.length} players in ${(performance.now() - before).toFixed(0)}ms`);
return results;
}
/**
* 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
> {
const before = performance.now();
this.log(`Looking up player "${playerId}"...`);
const token = await this.fetch<ScoreSaberPlayerToken>(LOOKUP_PLAYER_ENDPOINT.replace(":id", playerId));
if (token === undefined) {
return undefined;
}
this.log(`Found player "${playerId}" in ${(performance.now() - before).toFixed(0)}ms`);
return {
player: await getScoreSaberPlayerFromToken(apiUrl, token),
rawPlayer: token,
};
}
/**
* Lookup players on a specific page
*
* @param page the page to get players for
* @returns the players on the page, or undefined
*/
async lookupPlayers(page: number): Promise<ScoreSaberPlayersPageToken | undefined> {
const before = performance.now();
this.log(`Looking up players on page "${page}"...`);
const response = await this.fetch<ScoreSaberPlayersPageToken>(
LOOKUP_PLAYERS_ENDPOINT.replace(":page", page.toString())
);
if (response === undefined) {
return undefined;
}
this.log(`Found ${response.players.length} players in ${(performance.now() - before).toFixed(0)}ms`);
return response;
}
/**
* Lookup players on a specific page and country
*
* @param page the page to get players for
* @param country the country to get players for
* @returns the players on the page, or undefined
*/
async lookupPlayersByCountry(page: number, country: string): Promise<ScoreSaberPlayersPageToken | undefined> {
const before = performance.now();
this.log(`Looking up players on page "${page}" for country "${country}"...`);
const response = await this.fetch<ScoreSaberPlayersPageToken>(
LOOKUP_PLAYERS_BY_COUNTRY_ENDPOINT.replace(":page", page.toString()).replace(":country", country)
);
if (response === undefined) {
return undefined;
}
this.log(`Found ${response.players.length} players in ${(performance.now() - before).toFixed(0)}ms`);
return response;
}
/**
* Looks up a page of scores for a player
*
* @param playerId the ID of the player to look up
* @param sort the sort to use
* @param page the page to get scores for
* @param search
* @returns the scores of the player, or undefined
*/
async lookupPlayerScores({
playerId,
sort,
page,
search,
}: {
playerId: string;
sort: ScoreSort;
page: number;
search?: string;
useProxy?: boolean;
}): Promise<ScoreSaberPlayerScoresPageToken | undefined> {
const before = performance.now();
this.log(
`Looking up scores for player "${playerId}", sort "${sort}", page "${page}"${search ? `, search "${search}"` : ""}...`
);
const response = await this.fetch<ScoreSaberPlayerScoresPageToken>(
LOOKUP_PLAYER_SCORES_ENDPOINT.replace(":id", playerId)
.replace(":limit", 8 + "")
.replace(":sort", sort)
.replace(":page", page + "") + (search ? `&search=${search}` : "")
);
if (response === undefined) {
return undefined;
}
this.log(
`Found ${response.playerScores.length} scores for player "${playerId}" in ${(performance.now() - before).toFixed(0)}ms`
);
return response;
}
/**
* Looks up a leaderboard
*
* @param leaderboardId the ID of the leaderboard to look up
*/
async lookupLeaderboard(leaderboardId: string): Promise<ScoreSaberLeaderboardToken | undefined> {
const before = performance.now();
this.log(`Looking up leaderboard "${leaderboardId}"...`);
const response = await this.fetch<ScoreSaberLeaderboardToken>(
LOOKUP_LEADERBOARD_ENDPOINT.replace(":id", leaderboardId)
);
if (response === undefined) {
return undefined;
}
this.log(`Found leaderboard "${leaderboardId}" in ${(performance.now() - before).toFixed(0)}ms`);
return response;
}
/**
* Looks up a page of scores for a leaderboard
*
* @param leaderboardId the ID of the leaderboard to look up
* @param page the page to get scores for
* @returns the scores of the leaderboard, or undefined
*/
async lookupLeaderboardScores(
leaderboardId: string,
page: number
): Promise<ScoreSaberLeaderboardScoresPageToken | undefined> {
const before = performance.now();
this.log(`Looking up scores for leaderboard "${leaderboardId}", page "${page}"...`);
const response = await this.fetch<ScoreSaberLeaderboardScoresPageToken>(
LOOKUP_LEADERBOARD_SCORES_ENDPOINT.replace(":id", leaderboardId).replace(":page", page.toString())
);
if (response === undefined) {
return undefined;
}
this.log(
`Found ${response.scores.length} scores for leaderboard "${leaderboardId}" in ${(performance.now() - before).toFixed(0)}ms`
);
return response;
}
}
export const scoresaberService = new ScoreSaberService();

@ -0,0 +1,47 @@
import ky from "ky";
export default class Service {
/**
* The name of the service.
*/
private readonly name: string;
constructor(name: string) {
this.name = name;
}
/**
* Logs a message to the console.
*
* @param data the data to log
*/
public log(data: unknown) {
console.log(`[${this.name}]: ${data}`);
}
/**
* Builds a request url.
*
* @param useProxy whether to use proxy or not
* @param url the url to fetch
* @returns the request url
*/
private buildRequestUrl(useProxy: boolean, url: string): string {
return (useProxy ? "https://proxy.fascinated.cc/" : "") + url;
}
/**
* Fetches data from the given url.
*
* @param url the url to fetch
* @returns the fetched data
*/
public async fetch<T>(url: string): Promise<T | undefined> {
try {
return await ky.get<T>(this.buildRequestUrl(true, url)).json();
} catch (error) {
console.error(`Error fetching data from ${url}:`, error);
return undefined;
}
}
}

@ -0,0 +1,254 @@
import Player, { StatisticChange } from "../player";
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";
/**
* A ScoreSaber player.
*/
export default interface ScoreSaberPlayer extends Player {
/**
* The bio of the player.
*/
bio: ScoreSaberBio;
/**
* The amount of pp the player has.
*/
pp: number;
/**
* The change in pp compared to yesterday.
*/
statisticChange: StatisticChange | undefined;
/**
* The role the player has.
*/
role: ScoreSaberRole | undefined;
/**
* The badges the player has.
*/
badges: ScoreSaberBadge[];
/**
* The rank history for this player.
*/
statisticHistory: { [key: string]: PlayerHistory };
/**
* The statistics for this player.
*/
statistics: ScoreSaberPlayerStatistics;
/**
* The permissions the player has.
*/
permissions: number;
/**
* Whether the player is banned or not.
*/
banned: boolean;
/**
* Whether the player is inactive or not.
*/
inactive: boolean;
/**
* Whether the player is having their
* statistics being tracked or not.
*/
isBeingTracked?: boolean;
}
export async function getScoreSaberPlayerFromToken(
apiUrl: string,
token: ScoreSaberPlayerToken
): Promise<ScoreSaberPlayer> {
const bio: ScoreSaberBio = {
lines: token.bio?.split("\n") || [],
linesStripped: token.bio?.replace(/<[^>]+>/g, "")?.split("\n") || [],
};
const role = token.role == null ? undefined : (token.role as ScoreSaberRole);
const badges: ScoreSaberBadge[] =
token.badges?.map(badge => {
return {
url: badge.image,
description: badge.description,
};
}) || [];
let isBeingTracked = false;
const todayDate = formatDateMinimal(getMidnightAlignedDate(new Date()));
let statisticHistory: { [key: string]: PlayerHistory } = {};
try {
const history = await ky
.get<{
[key: string]: PlayerHistory;
}>(`${apiUrl}/api/player/history?id=${token.id}`)
.json();
if (history === undefined || Object.entries(history).length === 0) {
console.log("Player has no history, using fallback");
throw new Error();
}
if (history) {
// Use the latest data for today
history[todayDate] = {
rank: token.rank,
countryRank: token.countryRank,
pp: token.pp,
accuracy: {
averageRankedAccuracy: token.scoreStats.averageRankedAccuracy,
},
};
isBeingTracked = true;
}
statisticHistory = history;
} catch (error) {
// Fallback to ScoreSaber History if the player has no history
const playerRankHistory = token.histories.split(",").map(value => {
return parseInt(value);
});
playerRankHistory.push(token.rank);
let daysAgo = 0; // Start from current day
for (let i = playerRankHistory.length - 1; i >= 0; i--) {
const rank = playerRankHistory[i];
const date = getMidnightAlignedDate(getDaysAgoDate(daysAgo));
daysAgo += 1; // Increment daysAgo for each earlier rank
statisticHistory[formatDateMinimal(date)] = {
rank: rank,
};
}
}
// Sort the fallback history
statisticHistory = Object.entries(statisticHistory)
.sort((a, b) => Date.parse(b[0]) - Date.parse(a[0]))
.reduce((obj, [key, value]) => ({ ...obj, [key]: value }), {});
const yesterdayDate = formatDateMinimal(getMidnightAlignedDate(getDaysAgoDate(1)));
const todayStats = statisticHistory[todayDate];
const yesterdayStats = statisticHistory[yesterdayDate];
const hasChange = !!(todayStats && yesterdayStats);
/**
* Gets the change in the given stat
*
* @param statType the stat to check
* @return the change
*/
const getChange = (statType: "rank" | "countryRank" | "pp"): number => {
if (!hasChange) {
return 0;
}
const statToday = todayStats[`${statType}`];
const statYesterday = yesterdayStats[`${statType}`];
return !!(statToday && statYesterday) ? statToday - statYesterday : 0;
};
// Calculate the changes
const rankChange = getChange("rank");
const countryRankChange = getChange("countryRank");
const ppChange = getChange("pp");
return {
id: token.id,
name: token.name,
avatar: token.profilePicture,
country: token.country,
rank: token.rank,
countryRank: token.countryRank,
joinedDate: new Date(token.firstSeen),
bio: bio,
pp: token.pp,
statisticChange: {
rank: rankChange * -1, // Reverse the rank change
countryRank: countryRankChange * -1, // Reverse the country rank change
pp: ppChange,
},
role: role,
badges: badges,
statisticHistory: statisticHistory,
statistics: token.scoreStats,
permissions: token.permissions,
banned: token.banned,
inactive: token.inactive,
isBeingTracked: isBeingTracked,
};
}
/**
* A bio of a player.
*/
export type ScoreSaberBio = {
/**
* The lines of the bio including any html tags.
*/
lines: string[];
/**
* The lines of the bio stripped of all html tags.
*/
linesStripped: string[];
};
/**
* The ScoreSaber account roles.
*/
export type ScoreSaberRole = "Admin";
/**
* A badge for a player.
*/
export type ScoreSaberBadge = {
/**
* The URL to the badge.
*/
url: string;
/**
* The description of the badge.
*/
description: string;
};
/**
* The statistics for a player.
*/
export type ScoreSaberPlayerStatistics = {
/**
* The total amount of score accumulated over all scores.
*/
totalScore: number;
/**
* The total amount of ranked score accumulated over all scores.
*/
totalRankedScore: number;
/**
* The average ranked accuracy for all ranked scores.
*/
averageRankedAccuracy: number;
/**
* The total amount of scores set.
*/
totalPlayCount: number;
/**
* The total amount of ranked score set.
*/
rankedPlayCount: number;
/**
* The amount of times their replays were watched.
*/
replaysWatched: number;
};

@ -0,0 +1,26 @@
export interface PlayerHistory {
/**
* The player's rank.
*/
rank?: number;
/**
* The player's country rank.
*/
countryRank?: number;
/**
* The pp of the player.
*/
pp?: number;
/**
* The player's accuracy.
*/
accuracy?: {
/**
* The player's average ranked accuracy.
*/
averageRankedAccuracy?: number;
};
}

@ -0,0 +1,16 @@
export interface PlayerTrackedSince {
/**
* Whether the player statistics are being tracked
*/
tracked: boolean;
/**
* The date the player was first tracked
*/
trackedSince?: string;
/**
* The amount of days the player has been tracked
*/
daysTracked?: number;
}

@ -0,0 +1,58 @@
import { PlayerHistory } from "./player-history";
export default class Player {
/**
* The ID of this player.
*/
id: string;
/**
* The name of this player.
*/
name: string;
/**
* The avatar url for this player.
*/
avatar: string;
/**
* The country of this player.
*/
country: string;
/**
* The rank of the player.
*/
rank: number;
/**
* The rank the player has in their country.
*/
countryRank: number;
/**
* The date the player joined the playform.
*/
joinedDate: Date;
constructor(
id: string,
name: string,
avatar: string,
country: string,
rank: number,
countryRank: number,
joinedDate: Date
) {
this.id = id;
this.name = name;
this.avatar = avatar;
this.country = country;
this.rank = rank;
this.countryRank = countryRank;
this.joinedDate = joinedDate;
}
}
export type StatisticChange = PlayerHistory;

@ -0,0 +1,47 @@
import Score from "../score";
import { Modifier } from "../modifier";
import ScoreSaberScoreToken from "../../token/scoresaber/score-saber-score-token";
export default class ScoreSaberScore extends Score {
constructor(
score: number,
weight: number | undefined,
rank: number,
worth: number,
modifiers: Modifier[],
misses: number,
badCuts: number,
fullCombo: boolean,
timestamp: Date
) {
super(score, weight, rank, worth, modifiers, misses, badCuts, fullCombo, timestamp);
}
/**
* Gets a {@link ScoreSaberScore} from a {@link ScoreSaberScoreToken}.
*
* @param token the token to convert
*/
public static fromToken(token: ScoreSaberScoreToken): ScoreSaberScore {
const modifiers: Modifier[] = token.modifiers.split(",").map(mod => {
mod = mod.toUpperCase();
const modifier = Modifier[mod as keyof typeof Modifier];
if (modifier === undefined) {
throw new Error(`Unknown modifier: ${mod}`);
}
return modifier;
});
return new ScoreSaberScore(
token.baseScore,
token.weight,
token.rank,
token.pp,
modifiers,
token.missedNotes,
token.badCuts,
token.fullCombo,
new Date(token.timeSet)
);
}
}

@ -0,0 +1,18 @@
/**
* The score modifiers.
*/
export enum Modifier {
DA = "Disappearing Arrows",
FS = "Faster Song",
SF = "Super Fast Song",
SS = "Slower Song",
GN = "Ghost Notes",
NA = "No Arrows",
NO = "No Obstacles",
SA = "Strict Angles",
SC = "Small Notes",
PM = "Pro Mode",
CS = "Fail on Saber Clash",
IF = "One Life",
BE = "Battery Energy",
}

@ -0,0 +1,4 @@
export enum ScoreSort {
top = "top",
recent = "recent",
}

@ -0,0 +1,116 @@
import { Modifier } from "./modifier";
export default class Score {
/**
* The base score for the score.
* @private
*/
private readonly _score: number;
/**
* The weight of the score, or undefined if not ranked.s
* @private
*/
private readonly _weight: number | undefined;
/**
* The rank for the score.
* @private
*/
private readonly _rank: number;
/**
* The worth of the score (this could be pp, ap, cr, etc.),
* or undefined if not ranked.
* @private
*/
private readonly _worth: number;
/**
* The modifiers used on the score.
* @private
*/
private readonly _modifiers: Modifier[];
/**
* The amount missed notes.
* @private
*/
private readonly _misses: number;
/**
* The amount of bad cuts.
* @private
*/
private readonly _badCuts: number;
/**
* Whether every note was hit.
* @private
*/
private readonly _fullCombo: boolean;
/**
* The time the score was set.
* @private
*/
private readonly _timestamp: Date;
constructor(
score: number,
weight: number | undefined,
rank: number,
worth: number,
modifiers: Modifier[],
misses: number,
badCuts: number,
fullCombo: boolean,
timestamp: Date
) {
this._score = score;
this._weight = weight;
this._rank = rank;
this._worth = worth;
this._modifiers = modifiers;
this._misses = misses;
this._badCuts = badCuts;
this._fullCombo = fullCombo;
this._timestamp = timestamp;
}
get score(): number {
return this._score;
}
get weight(): number | undefined {
return this._weight;
}
get rank(): number {
return this._rank;
}
get worth(): number {
return this._worth;
}
get modifiers(): Modifier[] {
return this._modifiers;
}
get misses(): number {
return this._misses;
}
get badCuts(): number {
return this._badCuts;
}
get fullCombo(): boolean {
return this._fullCombo;
}
get timestamp(): Date {
return this._timestamp;
}
}

@ -0,0 +1,51 @@
export default interface BeatSaverAccountToken {
/**
* The id of the mapper
*/
id: number;
/**
* The name of the mapper.
*/
name: string;
/**
* The account hash of the mapper.
*/
hash: string;
/**
* The avatar url for the mapper.
*/
avatar: string;
/**
* The way the account was created
*/
type: string;
/**
* Whether the account is an admin or not.
*/
admin: boolean;
/**
* Whether the account is a curator or not.
*/
curator: boolean;
/**
* Whether the account is a senior curator or not.
*/
seniorCurator: boolean;
/**
* Whether the account is a verified mapper or not.
*/
verifiedMapper: boolean;
/**
* The playlist for the mappers songs.
*/
playlistUrl: string;
}

@ -0,0 +1,36 @@
export default interface BeatSaverMapMetadataToken {
/**
* The bpm of the song.
*/
bpm: number;
/**
* The song's length in seconds.
*/
duration: number;
/**
* The song's name.
*/
songName: string;
/**
* The songs sub name.
*/
songSubName: string;
/**
* The artist(s) name.
*/
songAuthorName: string;
/**
* The song's author's url.
*/
songAuthorUrl: string;
/**
* The level mapper(s) name.
*/
levelAuthorName: string;
}

@ -0,0 +1,31 @@
export default interface BeatSaverMapStatsToken {
/**
* The amount of time the map has been played.
*/
plays: number;
/**
* The amount of times the map has been downloaded.
*/
downloads: number;
/**
* The amount of times the map has been upvoted.
*/
upvotes: number;
/**
* The amount of times the map has been downvoted.
*/
downvotes: number;
/**
* The score for the map
*/
score: number;
/**
* The amount of reviews for the map.
*/
reviews: number;
}

@ -0,0 +1,24 @@
import BeatSaverAccountToken from "./beat-saver-account-token";
import BeatSaverMapMetadataToken from "./beat-saver-map-metadata-token";
import BeatSaverMapStatsToken from "./beat-saver-map-stats-token";
export interface BeatSaverMapToken {
id: string;
name: string;
description: string;
uploader: BeatSaverAccountToken;
metadata: BeatSaverMapMetadataToken;
stats: BeatSaverMapStatsToken;
uploaded: string;
automapper: boolean;
ranked: boolean;
qualified: boolean;
// todo: versions
createdAt: string;
updatedAt: string;
lastPublishedAt: string;
tags: string[];
declaredAi: string;
blRanked: boolean;
blQualified: boolean;
}

@ -0,0 +1,11 @@
export interface ScoreSaberBadgeToken {
/**
* The description of the badge.
*/
description: string;
/**
* The image of the badge.
*/
image: string;
}

@ -0,0 +1,6 @@
export default interface ScoreSaberDifficultyToken {
leaderboardId: number;
difficulty: number;
gameMode: string;
difficultyRaw: string;
}

@ -0,0 +1,8 @@
export default interface ScoreSaberLeaderboardPlayerInfoToken {
id: string;
name: string;
profilePicture: string;
country: string;
permissions: number;
role: string;
}

@ -0,0 +1,14 @@
import ScoreSaberMetadataToken from "./score-saber-metadata-token";
import ScoreSaberScoreToken from "./score-saber-score-token";
export default interface ScoreSaberLeaderboardScoresPageToken {
/**
* The scores on this page.
*/
scores: ScoreSaberScoreToken[];
/**
* The metadata for the page.
*/
metadata: ScoreSaberMetadataToken;
}

@ -0,0 +1,26 @@
import ScoreSaberDifficultyToken from "./score-saber-difficulty-token";
export default interface ScoreSaberLeaderboardToken {
id: number;
songHash: string;
songName: string;
songSubName: string;
songAuthorName: string;
levelAuthorName: string;
difficulty: ScoreSaberDifficultyToken;
maxScore: number;
createdDate: string;
rankedDate: string;
qualifiedDate: string;
lovedDate: string;
ranked: boolean;
qualified: boolean;
loved: boolean;
maxPP: number;
stars: number;
positiveModifiers: boolean;
plays: boolean;
dailyPlays: boolean;
coverImage: string;
difficulties: ScoreSaberDifficultyToken[];
}

@ -0,0 +1,16 @@
export default interface ScoreSaberMetadataToken {
/**
* The total amount of returned results.
*/
total: number;
/**
* The current page
*/
page: number;
/**
* The amount of results per page
*/
itemsPerPage: number;
}

@ -0,0 +1,14 @@
import ScoreSaberLeaderboardToken from "./score-saber-leaderboard-token";
import ScoreSaberScoreToken from "./score-saber-score-token";
export default interface ScoreSaberPlayerScoreToken {
/**
* The score of the player score.
*/
score: ScoreSaberScoreToken;
/**
* The leaderboard the score was set on.
*/
leaderboard: ScoreSaberLeaderboardToken;
}

@ -0,0 +1,14 @@
import ScoreSaberMetadataToken from "./score-saber-metadata-token";
import ScoreSaberPlayerScoreToken from "./score-saber-player-score-token";
export default interface ScoreSaberPlayerScoresPageToken {
/**
* The scores on this page.
*/
playerScores: ScoreSaberPlayerScoreToken[];
/**
* The metadata for the page.
*/
metadata: ScoreSaberMetadataToken;
}

@ -0,0 +1,8 @@
import ScoreSaberPlayerToken from "./score-saber-player-token";
export interface ScoreSaberPlayerSearchToken {
/**
* The players that were found
*/
players: ScoreSaberPlayerToken[];
}

@ -0,0 +1,84 @@
import { ScoreSaberBadgeToken } from "./score-saber-badge-token";
import ScoreSaberScoreStatsToken from "./score-saber-score-stats-token";
export default interface ScoreSaberPlayerToken {
/**
* The ID of the player.
*/
id: string;
/**
* The name of the player.
*/
name: string;
/**
* The profile picture of the player.
*/
profilePicture: string;
/**
* The bio of the player.
*/
bio: string | null;
/**
* The country of the player.
*/
country: string;
/**
* The amount of pp the player has.
*/
pp: number;
/**
* The rank of the player.
*/
rank: number;
/**
* The rank the player has in their country.
*/
countryRank: number;
/**
* The role of the player.
*/
role: string | null;
/**
* The badges the player has.
*/
badges: ScoreSaberBadgeToken[] | null;
/**
* The previous 50 days of rank history.
*/
histories: string;
/**
* The score stats of the player.
*/
scoreStats: ScoreSaberScoreStatsToken;
/**
* The permissions of the player. (bitwise)
*/
permissions: number;
/**
* Whether the player is banned or not.
*/
banned: boolean;
/**
* Whether the player is inactive or not.
*/
inactive: boolean;
/**
* The date the player joined ScoreSaber.
*/
firstSeen: string;
}

@ -0,0 +1,14 @@
import ScoreSaberMetadataToken from "./score-saber-metadata-token";
import ScoreSaberPlayerToken from "./score-saber-player-token";
export interface ScoreSaberPlayersPageToken {
/**
* The players that were found
*/
players: ScoreSaberPlayerToken[];
/**
* The metadata for the page.
*/
metadata: ScoreSaberMetadataToken;
}

@ -0,0 +1,31 @@
export default interface ScoreSaberScoreStatsToken {
/**
* The total amount of score accumulated over all scores.
*/
totalScore: number;
/**
* The total amount of ranked score accumulated over all scores.
*/
totalRankedScore: number;
/**
* The average ranked accuracy for all ranked scores.
*/
averageRankedAccuracy: number;
/**
* The total amount of scores set.
*/
totalPlayCount: number;
/**
* The total amount of ranked score set.
*/
rankedPlayCount: number;
/**
* The amount of times their replays were watched.
*/
replaysWatched: number;
}

@ -0,0 +1,25 @@
import ScoreSaberLeaderboardToken from "./score-saber-leaderboard-token";
import ScoreSaberLeaderboardPlayerInfoToken from "./score-saber-leaderboard-player-info-token";
export default interface ScoreSaberScoreToken {
id: string;
leaderboardPlayerInfo: ScoreSaberLeaderboardPlayerInfoToken;
rank: number;
baseScore: number;
modifiedScore: number;
pp: number;
weight: number;
modifiers: string;
multiplier: number;
badCuts: number;
missedNotes: number;
maxCombo: number;
fullCombo: boolean;
hmd: number;
hasReplay: boolean;
timeSet: string;
deviceHmd: string;
deviceControllerLeft: string;
deviceControllerRight: string;
leaderboard: ScoreSaberLeaderboardToken;
}

@ -0,0 +1,13 @@
import { PlayerHistory } from "../types/player/player-history";
/**
* Sorts the player history based on date,
* so the most recent date is first
*
* @param history the player history
*/
export function sortPlayerHistory(history: Map<string, PlayerHistory>) {
return Array.from(history.entries()).sort(
(a, b) => Date.parse(b[0]) - Date.parse(a[0]) // Sort in descending order
);
}

@ -0,0 +1,95 @@
/**
* This function returns the time ago of the input date
*
* @param input Date | number (timestamp)
* @returns the format of the time ago
*/
export function timeAgo(input: Date) {
const inputDate = new Date(input).getTime(); // Convert input to a Date object if it's not already
const now = new Date().getTime();
const deltaSeconds = Math.floor((now - inputDate) / 1000); // Get time difference in seconds
if (deltaSeconds <= 60) {
return "just now";
}
const timeUnits = [
{ unit: "y", seconds: 60 * 60 * 24 * 365 }, // years
{ unit: "mo", seconds: 60 * 60 * 24 * 30 }, // months
{ unit: "d", seconds: 60 * 60 * 24 }, // days
{ unit: "h", seconds: 60 * 60 }, // hours
{ unit: "m", seconds: 60 }, // minutes
];
const result = [];
let remainingSeconds = deltaSeconds;
for (const { unit, seconds } of timeUnits) {
const count = Math.floor(remainingSeconds / seconds);
if (count > 0) {
result.push(`${count}${unit}`);
remainingSeconds -= count * seconds;
}
// Stop after two units have been added
if (result.length === 2) break;
}
// Return formatted result with at most two units
return result.join(", ") + " ago";
}
/**
* Formats the date in the format "DD MMMM YYYY"
*
* @param date the date
*/
export function formatDateMinimal(date: Date) {
return date.toLocaleString("en-US", {
timeZone: "Europe/London",
day: "numeric",
month: "short",
year: "numeric",
});
}
/**
* Gets the midnight aligned date
*
* @param date the date
*/
export function getMidnightAlignedDate(date: Date) {
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
}
/**
* Gets the date X days ago
*
* @param days the number of days to go back
* @returns {Date} A Date object representing the date X days ago
*/
export function getDaysAgoDate(days: number): Date {
const date = new Date();
date.setDate(date.getDate() - days);
return date;
}
/**
* Gets the amount of days ago a date was
*
* @param date the date
* @returns the amount of days
*/
export function getDaysAgo(date: Date): number {
const now = new Date();
const diffTime = Math.abs(now.getTime() - date.getTime());
return Math.ceil(diffTime / (1000 * 60 * 60 * 24)) - 1;
}
/**
* Parses a date from a string
*
* @param date the date
*/
export function parseDate(date: string): Date {
return new Date(date);
}

@ -0,0 +1,6 @@
/**
* Checks if we're in production
*/
export function isProduction() {
return process.env.NODE_ENV === "production";
}

@ -0,0 +1,21 @@
{
"compilerOptions": {
"module": "ES2022",
"moduleResolution": "Bundler",
"target": "ES2022",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"skipLibCheck": true,
"strictNullChecks": false,
"noImplicitAny": false,
"strictBindCallApply": false,
"forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": false
}
}

@ -0,0 +1,10 @@
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
});

@ -0,0 +1,2 @@
node_modules
dist

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

@ -0,0 +1,8 @@
{
"extends": ["next/core-web-vitals", "next/typescript"],
"rules": {
"@typescript-eslint/no-unused-vars": "off",
"@typescript-eslint/no-unused-expressions": "off",
"@typescript-eslint/no-empty-object-type": "off"
}
}

41
projects/website/.gitignore vendored Normal file

@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
.idea
# Sentry Config File
.env.sentry-build-plugin

@ -0,0 +1,25 @@
FROM node:20-alpine3.17 AS base
# Install pnpm
RUN npm install -g pnpm
ENV PNPM_HOME=/usr/local/bin
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
ARG GIT_REV
ENV GIT_REV=${GIT_REV}
RUN pnpm install --filter website
RUN pnpm run build:website
# Expose the app port and start it
EXPOSE 3000
ENV HOSTNAME="0.0.0.0"
ENV PORT=3000
CMD ["pnpm", "start:website"]

@ -0,0 +1,20 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/app/globals.css",
"baseColor": "stone",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/app/components",
"utils": "@/app/common/utils",
"ui": "@/app/components/ui",
"lib": "@/app/common",
"hooks": "@/app/hooks"
}
}

@ -0,0 +1,3 @@
export const config = {
siteUrl: process.env.NEXT_PUBLIC_SITE_URL || "https://ssr.fascinated.cc",
};

@ -0,0 +1,51 @@
import { withSentryConfig } from "@sentry/nextjs";
import { format } from "@formkit/tempo";
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns: [
{
protocol: "https",
hostname: "cdn.scoresaber.com",
port: "",
pathname: "/**",
},
],
},
env: {
NEXT_PUBLIC_BUILD_ID: process.env.GIT_REV || "dev",
NEXT_PUBLIC_BUILD_TIME: new Date().toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
hour: "numeric",
minute: "numeric",
timeZoneName: "short",
}),
NEXT_PUBLIC_BUILD_TIME_SHORT: format(new Date(), {
date: "short",
time: "short",
}),
},
};
export default withSentryConfig(nextConfig, {
org: "scoresaber-reloaded",
project: "frontend",
sentryUrl: "https://glitchtip.fascinated.cc/",
silent: !process.env.CI,
reactComponentAnnotation: {
enabled: true,
},
tunnelRoute: "/monitoring",
hideSourceMaps: true,
disableLogger: true,
sourcemaps: {
disable: true,
},
release: {
create: false,
finalize: false,
},
});

@ -0,0 +1,65 @@
{
"name": "website",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbo",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@ssr/common": "workspace:*",
"@formkit/tempo": "^0.1.2",
"@heroicons/react": "^2.1.5",
"@hookform/resolvers": "^3.9.0",
"@radix-ui/react-avatar": "^1.1.0",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-scroll-area": "^1.1.0",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-toast": "^1.2.1",
"@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",
"clsx": "^2.1.1",
"comlink": "^4.4.1",
"dexie": "^4.0.8",
"dexie-react-hooks": "^1.1.7",
"framer-motion": "^11.5.4",
"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",
"react": "19.0.0-rc-3edc000d-20240926",
"react-chartjs-2": "^5.2.0",
"react-dom": "19.0.0-rc-3edc000d-20240926",
"react-hook-form": "^7.53.0",
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/js-cookie": "^3.0.6",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"eslint": "^8",
"eslint-config-next": "14.2.14",
"postcss": "^8",
"tailwindcss": "^3.4.1",
"typescript": "^5"
},
"trigger.dev": {
"endpointId": "scoresaber-reloaded-KB0Z"
}
}

@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
},
};
export default config;

Binary file not shown.

After

(image error) Size: 1.4 MiB

Binary file not shown.

After

(image error) Size: 841 B

Binary file not shown.

After

(image error) Size: 132 B

Binary file not shown.

After

(image error) Size: 1.1 KiB

Binary file not shown.

After

(image error) Size: 766 B

Binary file not shown.

After

(image error) Size: 659 B

Binary file not shown.

After

(image error) Size: 604 B

Binary file not shown.

After

(image error) Size: 121 B

Binary file not shown.

After

(image error) Size: 522 B

Binary file not shown.

After

(image error) Size: 445 B

Binary file not shown.

After

(image error) Size: 320 B

Binary file not shown.

After

(image error) Size: 909 B

Binary file not shown.

After

(image error) Size: 109 B

Binary file not shown.

After

(image error) Size: 554 B

Binary file not shown.

After

(image error) Size: 311 B

Binary file not shown.

After

(image error) Size: 179 B

Binary file not shown.

After

(image error) Size: 214 B

Binary file not shown.

After

(image error) Size: 339 B

Binary file not shown.

After

(image error) Size: 324 B

Binary file not shown.

After

(image error) Size: 282 B

Binary file not shown.

After

(image error) Size: 127 B

Binary file not shown.

After

(image error) Size: 254 B

Binary file not shown.

After

(image error) Size: 105 B

Binary file not shown.

After

(image error) Size: 326 B

Binary file not shown.

After

(image error) Size: 651 B

Binary file not shown.

After

(image error) Size: 127 B

Binary file not shown.

After

(image error) Size: 2.4 KiB

Binary file not shown.

After

(image error) Size: 1.1 KiB

Binary file not shown.

After

(image error) Size: 1.1 KiB

Binary file not shown.

After

(image error) Size: 132 B

Binary file not shown.

After

(image error) Size: 810 B

Binary file not shown.

After

(image error) Size: 792 B

Binary file not shown.

After

(image error) Size: 287 B

Binary file not shown.

After

(image error) Size: 1.4 KiB

Binary file not shown.

After

(image error) Size: 206 B

Binary file not shown.

After

(image error) Size: 139 B

Binary file not shown.

After

(image error) Size: 377 B

Binary file not shown.

After

(image error) Size: 1.3 KiB

Binary file not shown.

After

(image error) Size: 359 B

Binary file not shown.

After

(image error) Size: 561 B

Binary file not shown.

After

(image error) Size: 523 B

Binary file not shown.

After

(image error) Size: 286 B

Binary file not shown.

After

(image error) Size: 344 B

Binary file not shown.

After

(image error) Size: 135 B

Binary file not shown.

After

(image error) Size: 123 B

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