start backend work
2
projects/backend/.dockerignore
Normal file
@ -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
|
19
projects/backend/Dockerfile
Normal file
@ -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"]
|
9
projects/backend/README.md
Normal file
@ -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.
|
21
projects/backend/package.json
Normal file
@ -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"
|
||||
}
|
10
projects/backend/src/common/app-utils.ts
Normal file
@ -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");
|
||||
}
|
13
projects/backend/src/controller/app.ts
Normal file
@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
53
projects/backend/src/index.ts
Normal file
@ -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);
|
12
projects/backend/tsconfig.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2021",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "node",
|
||||
"types": ["bun-types"],
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true
|
||||
}
|
||||
}
|
2
projects/common/.dockerignore
Normal file
@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
dist
|
17
projects/common/package.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
49
projects/common/src/index.ts
Normal file
@ -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";
|
34
projects/common/src/service/impl/beatsaver.ts
Normal file
@ -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();
|
207
projects/common/src/service/impl/scoresaber.ts
Normal file
@ -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();
|
47
projects/common/src/service/service.ts
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
254
projects/common/src/types/player/impl/scoresaber-player.ts
Normal file
@ -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;
|
||||
};
|
26
projects/common/src/types/player/player-history.ts
Normal file
@ -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;
|
||||
};
|
||||
}
|
16
projects/common/src/types/player/player-tracked-since.ts
Normal file
@ -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;
|
||||
}
|
58
projects/common/src/types/player/player.ts
Normal file
@ -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;
|
47
projects/common/src/types/score/impl/scoresaber-score.ts
Normal file
@ -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)
|
||||
);
|
||||
}
|
||||
}
|
18
projects/common/src/types/score/modifier.ts
Normal file
@ -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",
|
||||
}
|
4
projects/common/src/types/score/score-sort.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export enum ScoreSort {
|
||||
top = "top",
|
||||
recent = "recent",
|
||||
}
|
116
projects/common/src/types/score/score.ts
Normal file
@ -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;
|
||||
}
|
8
projects/common/src/types/token/scoresaber/score-saber-leaderboard-player-info-token.ts
Normal file
@ -0,0 +1,8 @@
|
||||
export default interface ScoreSaberLeaderboardPlayerInfoToken {
|
||||
id: string;
|
||||
name: string;
|
||||
profilePicture: string;
|
||||
country: string;
|
||||
permissions: number;
|
||||
role: string;
|
||||
}
|
14
projects/common/src/types/token/scoresaber/score-saber-leaderboard-scores-page-token.ts
Normal file
@ -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;
|
||||
}
|
13
projects/common/src/utils/player-utils.ts
Normal file
@ -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
|
||||
);
|
||||
}
|
95
projects/common/src/utils/time-utils.ts
Normal file
@ -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);
|
||||
}
|
6
projects/common/src/utils/utils.ts
Normal file
@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Checks if we're in production
|
||||
*/
|
||||
export function isProduction() {
|
||||
return process.env.NODE_ENV === "production";
|
||||
}
|
21
projects/common/tsconfig.json
Normal file
@ -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
|
||||
}
|
||||
}
|
10
projects/common/tsup.config.ts
Normal file
@ -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
|
||||
});
|
2
projects/website/.dockerignore
Normal file
@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
dist
|
7
projects/website/.env-example
Normal file
@ -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=
|
8
projects/website/.eslintrc.json
Normal file
@ -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
|
25
projects/website/Dockerfile
Normal file
@ -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"]
|
20
projects/website/components.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
3
projects/website/config.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export const config = {
|
||||
siteUrl: process.env.NEXT_PUBLIC_SITE_URL || "https://ssr.fascinated.cc",
|
||||
};
|
51
projects/website/next.config.mjs
Normal file
@ -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,
|
||||
},
|
||||
});
|
65
projects/website/package.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
8
projects/website/postcss.config.mjs
Normal file
@ -0,0 +1,8 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
BIN
projects/website/public/assets/background.jpg
Normal file
After ![]() (image error) Size: 1.4 MiB |
BIN
projects/website/public/assets/flags/ad.png
Normal file
After ![]() (image error) Size: 841 B |
BIN
projects/website/public/assets/flags/ae.png
Normal file
After ![]() (image error) Size: 132 B |
BIN
projects/website/public/assets/flags/af.png
Normal file
After ![]() (image error) Size: 1.1 KiB |
BIN
projects/website/public/assets/flags/ag.png
Normal file
After ![]() (image error) Size: 766 B |
BIN
projects/website/public/assets/flags/ai.png
Normal file
After ![]() (image error) Size: 659 B |
BIN
projects/website/public/assets/flags/al.png
Normal file
After ![]() (image error) Size: 604 B |
BIN
projects/website/public/assets/flags/am.png
Normal file
After ![]() (image error) Size: 121 B |
BIN
projects/website/public/assets/flags/ao.png
Normal file
After ![]() (image error) Size: 522 B |
BIN
projects/website/public/assets/flags/aq.png
Normal file
After ![]() (image error) Size: 445 B |
BIN
projects/website/public/assets/flags/ar.png
Normal file
After ![]() (image error) Size: 320 B |
BIN
projects/website/public/assets/flags/as.png
Normal file
After ![]() (image error) Size: 909 B |
BIN
projects/website/public/assets/flags/at.png
Normal file
After ![]() (image error) Size: 109 B |
BIN
projects/website/public/assets/flags/au.png
Normal file
After ![]() (image error) Size: 554 B |
BIN
projects/website/public/assets/flags/aw.png
Normal file
After ![]() (image error) Size: 311 B |
BIN
projects/website/public/assets/flags/ax.png
Normal file
After ![]() (image error) Size: 179 B |
BIN
projects/website/public/assets/flags/az.png
Normal file
After ![]() (image error) Size: 214 B |
BIN
projects/website/public/assets/flags/ba.png
Normal file
After ![]() (image error) Size: 339 B |
BIN
projects/website/public/assets/flags/bb.png
Normal file
After ![]() (image error) Size: 324 B |
BIN
projects/website/public/assets/flags/bd.png
Normal file
After ![]() (image error) Size: 282 B |
BIN
projects/website/public/assets/flags/be.png
Normal file
After ![]() (image error) Size: 127 B |
BIN
projects/website/public/assets/flags/bf.png
Normal file
After ![]() (image error) Size: 254 B |
BIN
projects/website/public/assets/flags/bg.png
Normal file
After ![]() (image error) Size: 105 B |
BIN
projects/website/public/assets/flags/bh.png
Normal file
After ![]() (image error) Size: 326 B |
BIN
projects/website/public/assets/flags/bi.png
Normal file
After ![]() (image error) Size: 651 B |
BIN
projects/website/public/assets/flags/bj.png
Normal file
After ![]() (image error) Size: 127 B |
BIN
projects/website/public/assets/flags/bl.png
Normal file
After ![]() (image error) Size: 2.4 KiB |
BIN
projects/website/public/assets/flags/bm.png
Normal file
After ![]() (image error) Size: 1.1 KiB |
BIN
projects/website/public/assets/flags/bn.png
Normal file
After ![]() (image error) Size: 1.1 KiB |
BIN
projects/website/public/assets/flags/bo.png
Normal file
After ![]() (image error) Size: 132 B |
BIN
projects/website/public/assets/flags/bq.png
Normal file
After ![]() (image error) Size: 810 B |
BIN
projects/website/public/assets/flags/br.png
Normal file
After ![]() (image error) Size: 792 B |
BIN
projects/website/public/assets/flags/bs.png
Normal file
After ![]() (image error) Size: 287 B |
BIN
projects/website/public/assets/flags/bt.png
Normal file
After ![]() (image error) Size: 1.4 KiB |
BIN
projects/website/public/assets/flags/bv.png
Normal file
After ![]() (image error) Size: 206 B |
BIN
projects/website/public/assets/flags/bw.png
Normal file
After ![]() (image error) Size: 139 B |
BIN
projects/website/public/assets/flags/by.png
Normal file
After ![]() (image error) Size: 377 B |
BIN
projects/website/public/assets/flags/bz.png
Normal file
After ![]() (image error) Size: 1.3 KiB |
BIN
projects/website/public/assets/flags/ca.png
Normal file
After ![]() (image error) Size: 359 B |
BIN
projects/website/public/assets/flags/cc.png
Normal file
After ![]() (image error) Size: 561 B |
BIN
projects/website/public/assets/flags/cd.png
Normal file
After ![]() (image error) Size: 523 B |
BIN
projects/website/public/assets/flags/cf.png
Normal file
After ![]() (image error) Size: 286 B |
BIN
projects/website/public/assets/flags/cg.png
Normal file
After ![]() (image error) Size: 344 B |
BIN
projects/website/public/assets/flags/ch.png
Normal file
After ![]() (image error) Size: 135 B |
BIN
projects/website/public/assets/flags/ci.png
Normal file
After ![]() (image error) Size: 123 B |