diff --git a/backend/.env b/backend/.env index e730af6..1ea7a0d 100644 --- a/backend/.env +++ b/backend/.env @@ -1,3 +1,7 @@ PORT=4000 -MONGO_URI=mongodb://localhost:27017/fivem-tracker -DELAY_FIVEM_REQUESTS=100 \ No newline at end of file +FRONTEND_URL=http://localhost:3000 + +MYSQL_HOST=localhost +MYSQL_USER=root +MYSQL_PASSWORD= +MYSQL_DATABASE=fivem_tracker \ No newline at end of file diff --git a/backend/index.ts b/backend/index.ts deleted file mode 100644 index 4879a5a..0000000 --- a/backend/index.ts +++ /dev/null @@ -1,134 +0,0 @@ -import express from "express"; -import mongoose from "mongoose"; -var cron = require("node-cron"); - -import { fetchServers, GameName } from "./services/fivem.service"; - -import * as Server from "./services/server.service"; -import * as ServerHistory from "./services/server.history.service"; - -const MONGODB_URI = - process.env.MONGODB_URI || "mongodb://localhost:27017/fivem-tracker"; - -mongoose - .connect(MONGODB_URI) - .then(() => console.log("MongoDB connected")) - .catch((err) => console.error("Error connecting to MongoDB", err)); - -import { config } from "dotenv"; -config(); - -const PORT = process.env.PORT || 4000; - -const app = express(); - -app.use((req: any, res: any, next: any) => { - res.header("Access-Control-Allow-Origin", "*"); - next(); -}); - -app.get("/servers", (req: any, res: any) => { - Server.getAll() - .then((servers) => { - res.send(servers); - }) - .catch((error) => { - console.log("Error fetching servers", error); - res.status(500).send("Error fetching servers"); - }); -}); - -app.get("/server/:id", (req: any, res: any) => { - Server.get(req.params.id) - .then((server) => { - res.send(server); - }) - .catch((error) => { - console.log("Error fetching server", error); - res.status(500).send("Error fetching server"); - }); -}); - -app.get("/server/:id/history/:period", (req: any, res: any) => { - ServerHistory.get(req.params.id, req.params.period) - .then((history) => { - res.send(history); - }) - .catch((error) => { - console.log("Error fetching server history", error); - res.status(500).send("Error fetching server history"); - }); -}); - -app.get("/stats", (req: any, res: any) => { - // Server.getStats() - // .then((stats) => { - // res.send(stats); - // }) - // .catch((error) => { - // console.log("Error fetching stats", error); - // res.status(500).send("Error fetching stats"); - // }); -}); - -app.listen(PORT, async () => { - console.log(`Server is running on port ${PORT}`); -}); - -async function getServers() { - const serversToInsert: { id: string; clients: number; timestamp: Date }[] = - []; - const serversToUpdate: any[] = []; - const idsToDelete: Set = new Set(); - - await fetchServers(GameName.FiveM, async (server) => { - if (server.locale !== "de-DE") { - return; - } - - // Prepare data for bulk operations - serversToInsert.push({ - id: server.id, - clients: server.playersCurrent || 0, - timestamp: new Date(), - }); - - serversToUpdate.push(server); - idsToDelete.add(server.id); - }); - - // Bulk insert into ServerHistory - if (serversToInsert.length > 0) { - await ServerHistory.default.insertMany(serversToInsert); - } - - // Bulk update or upsert in Server - if (serversToUpdate.length > 0) { - const bulkOps = serversToUpdate.map((server) => ({ - updateOne: { - filter: { id: server.id }, - update: { $set: server }, - upsert: true, - }, - })); - await Server.default.bulkWrite(bulkOps); - } - - // Delete old entries from ServerHistory - if (idsToDelete.size > 0) { - await ServerHistory.default.deleteMany({ - id: { $in: Array.from(idsToDelete) }, - timestamp: { $lt: new Date(Date.now() - 30* 24 * 60 * 60 * 1000) }, // Delete entries older than 1 month - }); - } - - console.log("Servers updated"); -} - -cron.schedule("*/5 * * * *", async () => { - try { - await getServers(); - } catch (error) { - console.error("Error updating servers:", error); - } -}); diff --git a/backend/interfaces/IServer.ts b/backend/interfaces/IServer.ts deleted file mode 100644 index 1b7c8e0..0000000 --- a/backend/interfaces/IServer.ts +++ /dev/null @@ -1,5 +0,0 @@ -export default interface IServer { - id: string; - ip: string; -} - \ No newline at end of file diff --git a/backend/interfaces/IServerHistory.ts b/backend/interfaces/IServerHistory.ts deleted file mode 100644 index 76dc67b..0000000 --- a/backend/interfaces/IServerHistory.ts +++ /dev/null @@ -1,7 +0,0 @@ -interface IServerHistory { - id: string; - clients: number; - timestamp: Date; -} - -export default IServerHistory; diff --git a/backend/models/server.history.model.ts b/backend/models/server.history.model.ts deleted file mode 100644 index 29c4872..0000000 --- a/backend/models/server.history.model.ts +++ /dev/null @@ -1,18 +0,0 @@ -import mongoose, { Schema, Document } from "mongoose"; -import IServerHistory from "../interfaces/IServerHistory"; - -const serverSchema = new Schema({ - id: { type: String, required: true }, - clients: { type: Number, required: true }, - timestamp: { type: Date, required: true }, -}); - -const someRecentDate = new Date(); -someRecentDate.setDate(someRecentDate.getDate() - 30); - -serverSchema.index( - { id: 1, timestamp: 1 }, - { partialFilterExpression: { timestamp: { $gte: someRecentDate } } } -); - -export default mongoose.model("ServerHistory", serverSchema); diff --git a/backend/models/server.model.ts b/backend/models/server.model.ts deleted file mode 100644 index 4e14fe1..0000000 --- a/backend/models/server.model.ts +++ /dev/null @@ -1,82 +0,0 @@ -import mongoose, { Schema } from "mongoose"; -import { - IServerView, - ServerViewDetailsLevel, - IServerViewPlayer, - ServerPureLevel, -} from "../utils/types"; - -export enum SupportStatus { - Supported = "supported", - EndOfSupport = "end_of_support", - EndOfLife = "end_of_life", - Unknown = "unknown", -} - -const serverViewPlayerSchema = new Schema({ - endpoint: { type: String, required: true }, - id: { type: Number, required: true }, - identifiers: { type: [String], required: true }, - name: { type: String, required: true }, - ping: { type: Number, required: true }, -}); - -const serverSchema = new Schema({ - id: { type: String, required: true, unique: true }, - locale: { type: String, required: true }, - localeCountry: { type: String, required: true }, - hostname: { type: String, required: false }, - projectName: { type: String }, - rawVariables: { type: Map, of: String, required: true }, - joinId: { type: String }, - historicalAddress: { type: String }, - historicalIconURL: { type: String, default: null }, - connectEndPoints: [{ type: String }], - projectDescription: { type: String }, - upvotePower: { type: Number }, - burstPower: { type: Number }, - offline: { type: Boolean, default: false }, - iconVersion: { type: Number, default: null }, - licenseKeyToken: { type: String, default: null }, - mapname: { type: String, default: null }, - gametype: { type: String, default: null }, - gamename: { type: String, default: null }, - fallback: { type: Schema.Types.Mixed }, - private: { type: Boolean }, - scriptHookAllowed: { type: Boolean }, - enforceGameBuild: { type: String }, - pureLevel: { - type: String, - enum: Object.values(ServerPureLevel), - }, - premium: { - type: String, - enum: [null, "pt", "au", "ag"], - default: null, - }, - bannerConnecting: { type: String }, - bannerDetail: { type: String }, - canReview: { type: Boolean }, - ownerID: { type: String }, - ownerName: { type: String }, - ownerAvatar: { type: String }, - ownerProfile: { type: String }, - activitypubFeed: { type: String }, - onesyncEnabled: { type: Boolean }, - server: { type: String, default: null }, - supportStatus: { - type: String, - enum: Object.values(SupportStatus), - }, - playersMax: { type: Number }, - playersCurrent: { type: Number }, - tags: [{ type: String }], - players: [serverViewPlayerSchema], // Reference to player schema - resources: [{ type: String }], - variables: { type: Map, of: String }, -}); - -serverSchema.index({ playersCurrent: -1 }); // For sorting by playersCurrent -serverSchema.index({ id: 1 }, { unique: true }); // For unique constraint on id - -export default mongoose.model("Server", serverSchema); diff --git a/backend/package-lock.json b/backend/package-lock.json index c325c58..b43e52e 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -11,6 +11,7 @@ "emoji-regex": "^10.3.0", "express": "^4.19.2", "mongoose": "^8.3.2", + "mysql2": "^3.11.5", "node-cron": "^3.0.3", "nodemon": "^3.1.0", "protobufjs": "^7.3.2", @@ -18,6 +19,7 @@ "ts-node": "^10.9.2" }, "devDependencies": { + "@types/cors": "^2.8.17", "@types/express": "^4.17.21" } }, @@ -170,6 +172,16 @@ "@types/node": "*" } }, + "node_modules/@types/cors": { + "version": "2.8.17", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.17.tgz", + "integrity": "sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/express": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", @@ -366,6 +378,15 @@ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/axios": { "version": "1.6.8", "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.8.tgz", @@ -552,6 +573,7 @@ "version": "2.8.5", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", "dependencies": { "object-assign": "^4", "vary": "^1" @@ -618,6 +640,15 @@ "node": ">=0.4.0" } }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -904,6 +935,15 @@ "url": "/~https://github.com/sponsors/ljharb" } }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, "node_modules/get-intrinsic": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", @@ -1196,6 +1236,12 @@ "node": ">=0.12.0" } }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "license": "MIT" + }, "node_modules/jsbn": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", @@ -1237,6 +1283,21 @@ "node": ">=10" } }, + "node_modules/lru.min": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.1.tgz", + "integrity": "sha512-FbAj6lXil6t8z4z3j0E5mfRlPzxkySotzUHwRXjlpRh10vc6AI6WN62ehZj82VG7M20rqogJ0GLwar2Xa05a8Q==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "/~https://github.com/sponsors/wellwelwel" + } + }, "node_modules/make-error": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", @@ -1434,6 +1495,59 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, + "node_modules/mysql2": { + "version": "3.11.5", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.11.5.tgz", + "integrity": "sha512-0XFu8rUmFN9vC0ME36iBvCUObftiMHItrYFhlCRvFWbLgpNqtC4Br/NmZX1HNCszxT0GGy5QtP+k3Q3eCJPaYA==", + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.1", + "denque": "^2.1.0", + "generate-function": "^2.3.1", + "iconv-lite": "^0.6.3", + "long": "^5.2.1", + "lru.min": "^1.0.0", + "named-placeholders": "^1.1.3", + "seq-queue": "^0.0.5", + "sqlstring": "^2.3.2" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/mysql2/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.3.tgz", + "integrity": "sha512-eLoBxg6wE/rZkJPhU/xRX1WTpkFEwDJEN96oxFrTsqBdbT5ec295Q+CoHrL9IT0DipqKhmGcaZmwOt8OON5x1w==", + "license": "MIT", + "dependencies": { + "lru-cache": "^7.14.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/named-placeholders/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -1847,6 +1961,11 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, + "node_modules/seq-queue": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz", + "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==" + }, "node_modules/serve-static": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", @@ -1993,6 +2112,15 @@ "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==" }, + "node_modules/sqlstring": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", + "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", diff --git a/backend/package.json b/backend/package.json index dedd067..a9ecb01 100644 --- a/backend/package.json +++ b/backend/package.json @@ -6,6 +6,7 @@ "emoji-regex": "^10.3.0", "express": "^4.19.2", "mongoose": "^8.3.2", + "mysql2": "^3.11.5", "node-cron": "^3.0.3", "nodemon": "^3.1.0", "protobufjs": "^7.3.2", @@ -14,9 +15,10 @@ }, "scripts": { "build": "tsc", - "dev": "nodemon index.ts" + "dev": "nodemon server.ts" }, "devDependencies": { + "@types/cors": "^2.8.17", "@types/express": "^4.17.21" } } diff --git a/backend/server.ts b/backend/server.ts new file mode 100644 index 0000000..3575d50 --- /dev/null +++ b/backend/server.ts @@ -0,0 +1,18 @@ +import express, { Application } from "express"; +import Server from "./src/index"; + +const app: Application = express(); +const server: Server = new Server(app); +const PORT: number = process.env.PORT ? parseInt(process.env.PORT, 10) : 8080; + +app + .listen(PORT, "localhost", function () { + console.log(`Server is running on port ${PORT}.`); + }) + .on("error", (err: any) => { + if (err.code === "EADDRINUSE") { + console.log("Error: address already in use"); + } else { + console.log(err); + } + }); diff --git a/backend/services/server.history.service.ts b/backend/services/server.history.service.ts deleted file mode 100644 index c57ce33..0000000 --- a/backend/services/server.history.service.ts +++ /dev/null @@ -1,45 +0,0 @@ -import IServerHistory from "../interfaces/IServerHistory"; -import ServerHistory from "../models/server.history.model"; - -export async function insert(serverHistory: IServerHistory) { - await ServerHistory.create(serverHistory); -} - -export async function deleteOld(id: string) { - const currentTime = new Date().getTime(); - const oneMonthAgo = currentTime - 1209600000; - - await ServerHistory.deleteMany({ id: id, timestamp: { $lte: oneMonthAgo } }); -} - -export async function get(id: string, time: string) { - // time can be 1d 7d or 30d. convert it to milliseconds - let timeInMs = 0; - - switch (time) { - case "1d": - timeInMs = 86400000; - break; - case "7d": - timeInMs = 604800000; - break; - case "30d": - timeInMs = 2592000000; - break; - } - - const currentTime = new Date().getTime(); - const startTime = currentTime - timeInMs; - - // only return clients and timestamp - return await ServerHistory.find( - { id: id, timestamp: { $gte: startTime, $lte: currentTime } }, - { clients: 1, timestamp: 1 } - ); -} - -export async function exists(id: string) { - return await ServerHistory.exists({ id: id }); -} - -export default ServerHistory; diff --git a/backend/services/server.service.ts b/backend/services/server.service.ts deleted file mode 100644 index e2f4e90..0000000 --- a/backend/services/server.service.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { IServer, IServerView } from "../utils/types"; -import Server from "../models/server.model"; - -export async function insertOrUpdate(serverData: IServerView) { - const server = await Server.findOne({ id: serverData.id }); - - if (server) { - server.set(serverData); - await server.save(); - } else { - await Server.create(serverData); - } -} - -export async function getAll(amount: number = 300) { - // order by clients desc - return await Server.find( - {}) - .select( - { - id: 1, - connectEndPoints: 1, - hostname: 1, - playersCurrent: 1, - playersMax: 1, - mapname: 1, - historicalIconURL: 1, - iconVersion: 1, - joinId: 1, - gametype: 1, - } - ) - .sort({ playersCurrent: -1 }) - .limit(amount); -} - -export async function get(id: string) { - return await Server.findOne({ id: id }); -} - -// /* -// returns all players from all servers (count) -// returns all servers (count) -// */ -// export async function getStats() { -// const servers = await Server.find( -// {}, -// { EndPoint: 1, "Data.clients": 1, "Data.sv_maxclients": 1 } -// ); -// const players = servers.map((s) => s.Data.clients).reduce((a, b) => a + b, 0); -// const maxPlayers = servers -// .map((s) => s.Data.sv_maxclients) -// .reduce((a, b) => a + b, 0); - -// return { -// servers: servers.length, -// players, -// maxPlayers, -// }; -// } - -export default Server diff --git a/backend/src/controllers/home.controller.ts b/backend/src/controllers/home.controller.ts new file mode 100644 index 0000000..ee7f616 --- /dev/null +++ b/backend/src/controllers/home.controller.ts @@ -0,0 +1,5 @@ +import { Request, Response } from "express"; + +export function welcome(req: Request, res: Response): Response { + return res.json({ success: true, message: "Welcome to the API!" }); +} diff --git a/backend/src/controllers/server.controller.ts b/backend/src/controllers/server.controller.ts new file mode 100644 index 0000000..667fbf2 --- /dev/null +++ b/backend/src/controllers/server.controller.ts @@ -0,0 +1,55 @@ +import { Request, Response } from "express"; +import Server from "../models/server.model"; +import serverRepository from "../repositories/server.repository"; +import serverHistoryRepository from "../repositories/server.history.repository"; + +export default class ServerController { + async findAll(req: Request, res: Response) { + try { + const servers = await serverRepository.retrieveAll(); + + res.status(200).send(servers); + } catch (err) { + res.status(500).send({ + message: "Some error occurred while retrieving servers.", + }); + } + } + + async findOne(req: Request, res: Response) { + const id: string = String(req.params.id); + + try { + const server = await serverRepository.retrieveById(id); + + if (!server) { + return res.status(404).send({ message: "Server not found" }); + } + + res.status(200).send(server); + } catch (err) { + res.status(500).send({ + message: "Error retrieving server with id=" + id, + }); + } + } + + async findHistory(req: Request, res: Response) { + const id: string = String(req.params.id); + const period: string = String(req.params.period); + + try { + const server = await serverHistoryRepository.retrieveById(id, period); + + if (!server) { + return res.status(404).send({ message: "Server history not found" }); + } + + res.status(200).send(server); + } catch (err) { + res.status(500).send({ + message: "Error retrieving server history with id=" + id, + }); + } + } +} diff --git a/backend/src/database/index.ts b/backend/src/database/index.ts new file mode 100644 index 0000000..199db4e --- /dev/null +++ b/backend/src/database/index.ts @@ -0,0 +1,8 @@ +import mysql from "mysql2"; + +export default mysql.createConnection({ + host: process.env.MYSQL_HOST, + user: process.env.MYSQL_USER, + password: process.env.MYSQL_PASSWORD, + database: process.env.MYSQL_DATABASE, +}); diff --git a/backend/src/index.ts b/backend/src/index.ts new file mode 100644 index 0000000..fd6a2b3 --- /dev/null +++ b/backend/src/index.ts @@ -0,0 +1,90 @@ +import express, { Application } from "express"; +import cors, { CorsOptions } from "cors"; + +var cron = require("node-cron"); + +// import { fetchServers, GameName } from "../services/fivem.service"; + +// import * as Server from "../services/server.service"; +// import * as ServerHistory from "../services/server.history.service"; + +import { config } from "dotenv"; +config(); + +// app.get("/servers", (req: any, res: any) => { +// Server.getAll() +// .then((servers) => { +// res.send(servers); +// }) +// .catch((error) => { +// console.log("Error fetching servers", error); +// res.status(500).send("Error fetching servers"); +// }); +// }); + +// app.get("/server/:id", (req: any, res: any) => { +// Server.get(req.params.id) +// .then((server) => { +// res.send(server); +// }) +// .catch((error) => { +// console.log("Error fetching server", error); +// res.status(500).send("Error fetching server"); +// }); +// }); + +// app.get("/server/:id/history/:period", (req: any, res: any) => { +// ServerHistory.get(req.params.id, req.params.period) +// .then((history) => { +// res.send(history); +// }) +// .catch((error) => { +// console.log("Error fetching server history", error); +// res.status(500).send("Error fetching server history"); +// }); +// }); + +//app.get("/stats", (req: any, res: any) => { +// Server.getStats() +// .then((stats) => { +// res.send(stats); +// }) +// .catch((error) => { +// console.log("Error fetching stats", error); +// res.status(500).send("Error fetching stats"); +// }); +//}); + +//getServers(); + +import { getServers } from "./services/fivem.service"; +import Routes from "./routes/index"; + +export default class Server { + constructor(app: Application) { + this.config(app); + new Routes(app); + } + + private config(app: Application): void { + const corsOptions: CorsOptions = { + origin: process.env.FRONTEND_URL, + }; + + app.use(cors(corsOptions)); + app.use(express.json()); + app.use(express.urlencoded({ extended: true })); + + this.startCronJob(); + } + + private startCronJob(): void { + cron.schedule("*/5 * * * *", async () => { + try { + await getServers(); + } catch (error) { + console.error("Error updating servers:", error); + } + }); + } +} diff --git a/backend/src/models/server.history.model.ts b/backend/src/models/server.history.model.ts new file mode 100644 index 0000000..65fdac3 --- /dev/null +++ b/backend/src/models/server.history.model.ts @@ -0,0 +1,7 @@ +import { RowDataPacket } from "mysql2"; + +export default interface ServerHistory extends RowDataPacket { + id: string; + clients: number; + timestamp: number; +} diff --git a/backend/src/models/server.model.ts b/backend/src/models/server.model.ts new file mode 100644 index 0000000..bd89e77 --- /dev/null +++ b/backend/src/models/server.model.ts @@ -0,0 +1,35 @@ +import { RowDataPacket } from "mysql2"; + +export interface IServerViewPlayer { + endpoint: string; + id: number; + identifiers: string[]; + name: string; + ping: number; +} + +export default interface Server extends RowDataPacket { + id: string; + locale: string; + localeCountry: string; + hostname: string; + joinId: string; + projectName: string; + projectDescription: string; + upvotePower: number; + burstPower: number; + mapname: string; + gametype: string; + gamename: string; + private: boolean; + scriptHookAllowed: boolean; + enforceGameBuild: string; + bannerConnecting: string; + bannerDetail: string; + server: string; + playersMax: number; + playersCurrent: number; + tags: string[]; + players: IServerViewPlayer[]; + resources: string[]; +} diff --git a/backend/src/repositories/server.history.repository.ts b/backend/src/repositories/server.history.repository.ts new file mode 100644 index 0000000..f8ab131 --- /dev/null +++ b/backend/src/repositories/server.history.repository.ts @@ -0,0 +1,42 @@ +import { RowDataPacket, ResultSetHeader, OkPacket } from "mysql2"; + +import connection from "../database"; +import IServerHistory from "../models/server.history.model"; + +interface IServerHistoryRepository { + save(tutorial: IServerHistory): Promise; +} + +class ServerHistoryRepository implements IServerHistoryRepository { + save(server: IServerHistory): Promise { + return new Promise((resolve, reject) => { + connection.query( + "INSERT INTO server_history (id, clients) VALUES (?,?)", + [server.id, server.clients], + (err, res) => { + if (err) reject(err); + else resolve; + } + ); + }); + } + + async retrieveById(id: string, period: string): Promise { + if (!period) period = "30"; + period = period.replace("d", "") || "30"; + + const query: string = `SELECT * FROM server_history WHERE id = ? AND timestamp > DATE_SUB(NOW(), INTERVAL ? DAY) ORDER BY timestamp ASC`; + + return new Promise((resolve, reject) => { + connection.query(query, [id, period], (err, res) => { + if (err) { + reject(err); + } else { + resolve(res); + } + }); + }); + } +} + +export default new ServerHistoryRepository(); diff --git a/backend/src/repositories/server.repository.ts b/backend/src/repositories/server.repository.ts new file mode 100644 index 0000000..73863fa --- /dev/null +++ b/backend/src/repositories/server.repository.ts @@ -0,0 +1,117 @@ +import { OkPacket } from "mysql2"; +import connection from "../database"; +import IServer from "../models/server.model"; + +interface IServerRepository { + save(tutorial: IServer): Promise; + retrieveAll(): Promise; + retrieveById(id: string): Promise; +} + +class ServerRepository implements IServerRepository { + retrieveAll(): Promise { + let timestamp = performance.now(); + + let query: string = + "SELECT *, ROW_NUMBER() OVER (ORDER BY playersCurrent DESC) AS rank FROM servers ORDER BY playersCurrent DESC"; + + return new Promise((resolve, reject) => { + connection.query(query, (err, res) => { + console.log( + "Time to fetch servers", + performance.now() - timestamp, + "ms" + ); + if (err) reject(err); + else resolve(res); + }); + }); + } + + save(server: IServer): Promise { + return new Promise((resolve, reject) => { + connection.query( + "INSERT INTO servers (id, locale, localeCountry, hostname, joinId, projectName, projectDescription, upvotePower, burstPower, mapname, gametype, gamename, private, scriptHookAllowed, enforceGameBuild, bannerConnecting, bannerDetail, server, playersMax, playersCurrent, iconVersion, tags, resources, players) " + + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) " + + "ON DUPLICATE KEY UPDATE " + + "locale = VALUES(locale), " + + "localeCountry = VALUES(localeCountry), " + + "hostname = VALUES(hostname), " + + "joinId = VALUES(joinId), " + + "projectName = VALUES(projectName), " + + "projectDescription = VALUES(projectDescription), " + + "upvotePower = VALUES(upvotePower), " + + "burstPower = VALUES(burstPower), " + + "mapname = VALUES(mapname), " + + "gametype = VALUES(gametype), " + + "gamename = VALUES(gamename), " + + "private = VALUES(private), " + + "scriptHookAllowed = VALUES(scriptHookAllowed), " + + "enforceGameBuild = VALUES(enforceGameBuild), " + + "bannerConnecting = VALUES(bannerConnecting), " + + "bannerDetail = VALUES(bannerDetail), " + + "server = VALUES(server), " + + "playersMax = VALUES(playersMax), " + + "playersCurrent = VALUES(playersCurrent), " + + "iconVersion = VALUES(iconVersion), " + + "tags = VALUES(tags), " + + "resources = VALUES(resources), " + + "players = VALUES(players)", + [ + server.id, + server.locale, + server.localeCountry, + server.hostname, + server.joinId, + server.projectName, + server.projectDescription, + server.upvotePower, + server.burstPower, + server.mapname, + server.gametype, + server.gamename, + server.private, + server.scriptHookAllowed, + server.enforceGameBuild, + server.bannerConnecting, + server.bannerDetail, + server.server, + server.playersMax, + server.playersCurrent, + server.iconVersion, + JSON.stringify(server.tags), + JSON.stringify(server.resources), + JSON.stringify(server.players), + ], + (err, res) => { + if (err) reject(err); + else resolve; + } + ); + }); + } + + async retrieveById(id: string): Promise { + let query: string = "SELECT * FROM servers WHERE id = ?"; + + return new Promise((resolve, reject) => { + connection.query(query, [id], (err, res) => { + if (err) reject(err); + else resolve(res?.[0]); + }); + }); + } + async deleteOldServers() { + let query: string = + "DELETE FROM servers WHERE updated_at < NOW() - INTERVAL 30 DAY"; + + return new Promise((resolve, reject) => { + connection.query(query, (err, res) => { + if (err) reject(err); + else resolve(res); + }); + }); + } +} + +export default new ServerRepository(); diff --git a/backend/src/routes/home.routes.ts b/backend/src/routes/home.routes.ts new file mode 100644 index 0000000..e06055c --- /dev/null +++ b/backend/src/routes/home.routes.ts @@ -0,0 +1,16 @@ +import { Router } from "express"; +import { welcome } from "../controllers/home.controller"; + +class HomeRoutes { + router = Router(); + + constructor() { + this.intializeRoutes(); + } + + intializeRoutes() { + this.router.get("/", welcome); + } +} + +export default new HomeRoutes().router; diff --git a/backend/src/routes/index.ts b/backend/src/routes/index.ts new file mode 100644 index 0000000..5510a12 --- /dev/null +++ b/backend/src/routes/index.ts @@ -0,0 +1,10 @@ +import { Application } from "express"; +import homeRoutes from "./home.routes"; +import serverRoutes from "./server.routes"; + +export default class Routes { + constructor(app: Application) { + app.use("/", homeRoutes); + app.use("/api", serverRoutes); + } +} diff --git a/backend/src/routes/server.routes.ts b/backend/src/routes/server.routes.ts new file mode 100644 index 0000000..024faba --- /dev/null +++ b/backend/src/routes/server.routes.ts @@ -0,0 +1,19 @@ +import { Router } from "express"; +import ServerController from "../controllers/server.controller"; + +class ServerRoutes { + router = Router(); + controller = new ServerController(); + + constructor() { + this.intializeRoutes(); + } + + intializeRoutes() { + this.router.get("/servers", this.controller.findAll); + this.router.get("/server/:id", this.controller.findOne); + this.router.get("/server/:id/history/:period", this.controller.findHistory); + } +} + +export default new ServerRoutes().router; diff --git a/backend/services/fivem.service.ts b/backend/src/services/fivem.service.ts similarity index 53% rename from backend/services/fivem.service.ts rename to backend/src/services/fivem.service.ts index 24e219a..5f4bc32 100644 --- a/backend/services/fivem.service.ts +++ b/backend/src/services/fivem.service.ts @@ -7,8 +7,6 @@ import { IServerView } from "../utils/types"; const BASE_URL = "https://servers-frontend.fivem.net/api/servers"; const ALL_SERVERS_URL = `${BASE_URL}/streamRedir/`; -const SINGLE_SERVER_URL = `${BASE_URL}/single/`; -const TOP_SERVER_URL = `${BASE_URL}/top/`; export enum GameName { FiveM = "gta5", @@ -98,3 +96,72 @@ export async function fetchServers( console.timeEnd("Total fetchServers"); } } + +import serverRepository from "../repositories/server.repository"; +import IServer from "../models/server.model"; +import IServerHistory from "../models/server.history.model"; +import serverHistoryRepository from "../repositories/server.history.repository"; +getServers(); +export async function getServers() { + await fetchServers(GameName.FiveM, async (server) => { + if (server.locale !== "de-DE") { + return; + } + + if (server.id === "g8lqro") { + console.log(server); + } + + // parse server to Server model + const serverModel: IServer = { + id: server.id, + locale: server.locale, + localeCountry: server.localeCountry, + hostname: server.hostname, + joinId: server.joinId ?? "", + projectName: server.projectName, + projectDescription: server.projectDescription ?? "", + upvotePower: server.upvotePower ?? 0, + burstPower: server.burstPower ?? 0, + mapname: server.mapname ?? "", + gametype: server.gametype ?? "", + gamename: server.gamename ?? "", + private: server.private ?? false, + scriptHookAllowed: server.scriptHookAllowed ?? false, + enforceGameBuild: server.enforceGameBuild ?? "", + bannerConnecting: server.bannerConnecting ?? "", + bannerDetail: server.bannerDetail ?? "", + server: server.server ?? "", + playersMax: server.playersMax ?? 0, + playersCurrent: server.playersCurrent ?? 0, + iconVersion: server.iconVersion ?? 0, + tags: server.tags ?? [], + players: server.players ?? [], + resources: server.resources ?? [], + constructor: { + name: "RowDataPacket", + }, + }; + + const serverHistoryModel: IServerHistory = { + id: server.id, + clients: server.playersCurrent ?? 0, + timestamp: new Date().getTime(), + constructor: { + name: "RowDataPacket", + }, + }; + + serverRepository.save(serverModel); + serverHistoryRepository.save(serverHistoryModel); + }); + + deleteOldServers(); +} + +// delete old servers where servers.updated_at < now - 30 day +export async function deleteOldServers() { + const result: any = await serverRepository.deleteOldServers(); + + console.log(`Deleted ${result?.affectedRows} old servers`); +} diff --git a/backend/utils/api.ts b/backend/src/utils/api.ts similarity index 100% rename from backend/utils/api.ts rename to backend/src/utils/api.ts diff --git a/backend/utils/async.ts b/backend/src/utils/async.ts similarity index 100% rename from backend/utils/async.ts rename to backend/src/utils/async.ts diff --git a/backend/utils/disposable.ts b/backend/src/utils/disposable.ts similarity index 100% rename from backend/utils/disposable.ts rename to backend/src/utils/disposable.ts diff --git a/backend/utils/fetcher.ts b/backend/src/utils/fetcher.ts similarity index 100% rename from backend/utils/fetcher.ts rename to backend/src/utils/fetcher.ts diff --git a/backend/utils/frameReader.ts b/backend/src/utils/frameReader.ts similarity index 100% rename from backend/utils/frameReader.ts rename to backend/src/utils/frameReader.ts diff --git a/backend/utils/master.d.ts b/backend/src/utils/master.d.ts similarity index 100% rename from backend/utils/master.d.ts rename to backend/src/utils/master.d.ts diff --git a/backend/utils/master.js b/backend/src/utils/master.js similarity index 100% rename from backend/utils/master.js rename to backend/src/utils/master.js diff --git a/backend/utils/serverUtils.ts b/backend/src/utils/serverUtils.ts similarity index 100% rename from backend/utils/serverUtils.ts rename to backend/src/utils/serverUtils.ts diff --git a/backend/utils/transformers.ts b/backend/src/utils/transformers.ts similarity index 100% rename from backend/utils/transformers.ts rename to backend/src/utils/transformers.ts diff --git a/backend/utils/types.ts b/backend/src/utils/types.ts similarity index 100% rename from backend/utils/types.ts rename to backend/src/utils/types.ts diff --git a/docker-compose.yml b/docker-compose.yml index ced7fd5..a9b2155 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,13 +1,16 @@ version: '3' services: - mongodb: - image: mongo - container_name: database - ports: - - "27018:27017" + mariadb: + image: mariadb:latest + container_name: mariadb + environment: + MYSQL_ROOT_PASSWORD: fivem_tracker + MYSQL_DATABASE: fivem_tracker + MYSQL_USER: fivem_tracker + MYSQL_PASSWORD: fivem_tracker volumes: - - mongodb_data:/data/db + - mariadb_data:/var/lib/mysql networks: - app_network @@ -17,11 +20,14 @@ services: dockerfile: Dockerfile container_name: backend environment: - - MONGODB_URI=mongodb://database:27017/fivem-tracker # Updated MongoDB URI + - MYSQL_HOST=mariadb + - MYSQL_USER=fivem_tracker + - MYSQL_PASSWORD=fivem_tracker + - MYSQL_DATABASE=fivem_tracker ports: - "4000:4000" depends_on: - - mongodb + - mariadb networks: - app_network @@ -30,6 +36,8 @@ services: context: ./frontend dockerfile: Dockerfile container_name: frontend + environment: + - REACT_APP_API_URL=https://tracker.louis.sytems/ ports: - "3000:80" networks: @@ -39,4 +47,4 @@ networks: app_network: volumes: - mongodb_data: + mariadb_data: diff --git a/frontend/.env b/frontend/.env index 83b7d8e..e7256a3 100644 --- a/frontend/.env +++ b/frontend/.env @@ -1 +1 @@ -REACT_APP_API_URL=https://api.louis.systems/ \ No newline at end of file +REACT_APP_API_URL=http://localhost:4000/ \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 64e937f..c95b2ca 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,21 +8,21 @@ "name": "frontend", "version": "0.1.0", "dependencies": { - "@coreui/icons": "^3.0.1", - "@coreui/icons-react": "^2.2.1", - "@coreui/react": "^5.0.0", - "@coreui/react-chartjs": "^3.0.0", + "@fontsource/poppins": "^5.1.0", "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", "axios": "^1.6.8", - "bootstrap": "^5.3.3", "lightweight-charts": "^4.1.3", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-icons": "^5.4.0", "react-router-dom": "^6.22.3", "react-scripts": "5.0.1", "web-vitals": "^2.1.4" + }, + "devDependencies": { + "tailwindcss": "^3.4.17" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -2037,70 +2037,6 @@ "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==" }, - "node_modules/@coreui/chartjs": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@coreui/chartjs/-/chartjs-4.0.0.tgz", - "integrity": "sha512-gPxmqj6hpC/erZBfyKQ+axWKr1gY4yhj8Dm3WkBp8SG2lUs0lEAQy3XGmmM/42TBTylbq5V4P6jfqim3N0mKmw==", - "dependencies": { - "@coreui/coreui": "^5.0.0", - "chart.js": "^4.4.2" - } - }, - "node_modules/@coreui/coreui": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@coreui/coreui/-/coreui-5.0.0.tgz", - "integrity": "sha512-okQEoPVOqIQOH9aDN9jfgrjuMTgZ1YX/S81wWP5WuMmx5u0s8K+BXXDja5bbl4eP703+rElew/tHqGEAn+89Gw==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/coreui" - } - ], - "peerDependencies": { - "@popperjs/core": "^2.11.8" - } - }, - "node_modules/@coreui/icons": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@coreui/icons/-/icons-3.0.1.tgz", - "integrity": "sha512-u9UKEcRMyY9pa4jUoLij8pAR03g5g6TLWV33/Mx2ix8sffyi0eO4fLV8DSTQljDCw938zt7KYog5cVKEAJUxxg==" - }, - "node_modules/@coreui/icons-react": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@coreui/icons-react/-/icons-react-2.2.1.tgz", - "integrity": "sha512-44bdKV5fZpVRpY6M7AL15tSAB2S4/xFzAojMsYe9k46mjyX7QLAKoowioEtLXxOqCRUBBBgxAXyjvJeeJlZwxg==", - "peerDependencies": { - "react": ">=17", - "react-dom": ">=17" - } - }, - "node_modules/@coreui/react": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@coreui/react/-/react-5.0.0.tgz", - "integrity": "sha512-LnYloIOq3T1mgZNEEoni1yScsg3ad2r/q1tUQt0+HabyiYXV4OVHWXv0Y7sneUWNzv5yx+3BlJldGHVXDRuImQ==", - "dependencies": { - "@coreui/coreui": "^5.0.0", - "@popperjs/core": "^2.11.8", - "prop-types": "^15.8.1" - }, - "peerDependencies": { - "react": ">=17", - "react-dom": ">=17" - } - }, - "node_modules/@coreui/react-chartjs": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@coreui/react-chartjs/-/react-chartjs-3.0.0.tgz", - "integrity": "sha512-65FAxHHwfMp3HY2yej7a0sLgkQJ49v9oPisuwvShrHOo0cuFVaUGKSa1V+M13W/oKsS6AWEFUooAZdsz/WZrAg==", - "dependencies": { - "@coreui/chartjs": "^4.0.0", - "chart.js": "^4.4.2" - }, - "peerDependencies": { - "react": ">=17", - "react-dom": ">=17" - } - }, "node_modules/@csstools/normalize.css": { "version": "12.1.1", "resolved": "https://registry.npmjs.org/@csstools/normalize.css/-/normalize.css-12.1.1.tgz", @@ -2464,6 +2400,12 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@fontsource/poppins": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fontsource/poppins/-/poppins-5.1.0.tgz", + "integrity": "sha512-tpLXlnNi2fwQjiipvuj4uNFHCdoLA8izRsKdoexZuEzjx0r/g1aKLf4ta6lFgF7L+/+AFdmaXFlUwwvmDzYH+g==", + "license": "OFL-1.1" + }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.14", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", @@ -3317,11 +3259,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@kurkle/color": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.2.tgz", - "integrity": "sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw==" - }, "node_modules/@leichtgewicht/ip-codec": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", @@ -3445,15 +3382,6 @@ } } }, - "node_modules/@popperjs/core": { - "version": "2.11.8", - "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", - "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/popperjs" - } - }, "node_modules/@remix-run/router": { "version": "1.15.3", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.15.3.tgz", @@ -5970,24 +5898,6 @@ "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==" }, - "node_modules/bootstrap": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.3.tgz", - "integrity": "sha512-8HLCdWgyoMguSO9o+aH+iuZ+aht+mzW0u3HIMzVu7Srrpv7EBBxTnrFlSCskwdY1+EOFQSm7uMJhNQHkdPcmjg==", - "funding": [ - { - "type": "github", - "url": "/~https://github.com/sponsors/twbs" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/bootstrap" - } - ], - "peerDependencies": { - "@popperjs/core": "^2.11.8" - } - }, "node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -5998,11 +5908,12 @@ } }, "node_modules/braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", "dependencies": { - "fill-range": "^7.0.1" + "fill-range": "^7.1.1" }, "engines": { "node": ">=8" @@ -6189,17 +6100,6 @@ "node": ">=10" } }, - "node_modules/chart.js": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.2.tgz", - "integrity": "sha512-6GD7iKwFpP5kbSD4MeRRRlTnQvxfQREy36uEtm1hzHzcOqwWx0YEHuspuoNlslu+nciLIB7fjjsHkUv/FzFcOg==", - "dependencies": { - "@kurkle/color": "^0.3.0" - }, - "engines": { - "pnpm": ">=8" - } - }, "node_modules/check-types": { "version": "11.2.3", "resolved": "https://registry.npmjs.org/check-types/-/check-types-11.2.3.tgz", @@ -8621,9 +8521,10 @@ } }, "node_modules/fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" }, @@ -9979,6 +9880,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", "engines": { "node": ">=0.12.0" } @@ -12299,9 +12201,10 @@ } }, "node_modules/jiti": { - "version": "1.21.0", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.0.tgz", - "integrity": "sha512-gFqAIbuKyyso/3G2qhiO2OM6shY6EPP/R0+mkDbyspxKazh8BXDC5FiFsUjlczgdNz/vfra0da2y+aHrusLG/Q==", + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "license": "MIT", "bin": { "jiti": "bin/jiti.js" } @@ -12757,11 +12660,12 @@ } }, "node_modules/micromatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", - "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", "dependencies": { - "braces": "^3.0.2", + "braces": "^3.0.3", "picomatch": "^2.3.1" }, "engines": { @@ -13488,9 +13392,10 @@ "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==" }, "node_modules/picocolors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==" + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" }, "node_modules/picomatch": { "version": "2.3.1", @@ -13654,9 +13559,9 @@ } }, "node_modules/postcss": { - "version": "8.4.38", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz", - "integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==", + "version": "8.4.49", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz", + "integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==", "funding": [ { "type": "opencollective", @@ -13671,10 +13576,11 @@ "url": "/~https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { "nanoid": "^3.3.7", - "picocolors": "^1.0.0", - "source-map-js": "^1.2.0" + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12 || >=14" @@ -14356,19 +14262,26 @@ } }, "node_modules/postcss-nested": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.0.1.tgz", - "integrity": "sha512-mEp4xPMi5bSWiMbsgoPfcP74lsWLHkQbZc3sY+jWYd65CUwXrUaTp0fmNpa01ZcETKlIgUdFN/MpS2xZtqL9dQ==", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "/~https://github.com/sponsors/ai" + } + ], + "license": "MIT", "dependencies": { - "postcss-selector-parser": "^6.0.11" + "postcss-selector-parser": "^6.1.1" }, "engines": { "node": ">=12.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, "peerDependencies": { "postcss": "^8.2.14" } @@ -14754,9 +14667,10 @@ } }, "node_modules/postcss-selector-parser": { - "version": "6.0.16", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.16.tgz", - "integrity": "sha512-A0RVJrX+IUkVZbW3ClroRWurercFhieevHB38sr2+l9eUClMqome3LmEmnhlNy+5Mr2EYN6B2Kaw9wYdd+VHiw==", + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "license": "MIT", "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -15252,6 +15166,15 @@ "resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.11.tgz", "integrity": "sha512-/6UZ2qgEyH2aqzYZgQPxEnz33NJ2gNsnHA2o5+o4wW9bLM/JYQitNP9xPhsXwC08hMMovfGe/8retsdDsczPRg==" }, + "node_modules/react-icons": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.4.0.tgz", + "integrity": "sha512-7eltJxgVt7X64oHh6wSWNwwbKTCtMfK35hcjvJS0yxEAhPM8oUKdS3+kqaW1vicIltw+kR2unHaa12S9pPALoQ==", + "license": "MIT", + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", @@ -16244,9 +16167,10 @@ } }, "node_modules/source-map-js": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", - "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } @@ -16930,32 +16854,33 @@ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==" }, "node_modules/tailwindcss": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.3.tgz", - "integrity": "sha512-U7sxQk/n397Bmx4JHbJx/iSOOv5G+II3f1kpLpY2QeUv5DcPdcTsYLlusZfq1NthHS1c1cZoyFmmkex1rzke0A==", + "version": "3.4.17", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", + "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", + "license": "MIT", "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", - "chokidar": "^3.5.3", + "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", - "fast-glob": "^3.3.0", + "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", - "jiti": "^1.21.0", - "lilconfig": "^2.1.0", - "micromatch": "^4.0.5", + "jiti": "^1.21.6", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", - "picocolors": "^1.0.0", - "postcss": "^8.4.23", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", - "postcss-load-config": "^4.0.1", - "postcss-nested": "^6.0.1", - "postcss-selector-parser": "^6.0.11", - "resolve": "^1.22.2", - "sucrase": "^3.32.0" + "postcss-load-config": "^4.0.2", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", @@ -16965,6 +16890,18 @@ "node": ">=14.0.0" } }, + "node_modules/tailwindcss/node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "/~https://github.com/sponsors/antonk52" + } + }, "node_modules/tapable": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", @@ -17143,6 +17080,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", "dependencies": { "is-number": "^7.0.0" }, diff --git a/frontend/package.json b/frontend/package.json index 7f11162..3457572 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -3,24 +3,21 @@ "version": "0.1.0", "private": true, "dependencies": { - "@coreui/icons": "^3.0.1", - "@coreui/icons-react": "^2.2.1", - "@coreui/react": "^5.0.0", - "@coreui/react-chartjs": "^3.0.0", + "@fontsource/poppins": "^5.1.0", "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", "axios": "^1.6.8", - "bootstrap": "^5.3.3", "lightweight-charts": "^4.1.3", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-icons": "^5.4.0", "react-router-dom": "^6.22.3", "react-scripts": "5.0.1", "web-vitals": "^2.1.4" }, "scripts": { - "start": "react-scripts start", + "dev": "react-scripts start", "build": "react-scripts build", "test": "react-scripts test", "eject": "react-scripts eject" @@ -42,5 +39,8 @@ "last 1 firefox version", "last 1 safari version" ] + }, + "devDependencies": { + "tailwindcss": "^3.4.17" } } diff --git a/frontend/public/index.html b/frontend/public/index.html index 5994932..eb33c87 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -1,5 +1,5 @@ - + diff --git a/frontend/src/components/App.jsx b/frontend/src/components/App.jsx index 321cb39..2ea840d 100644 --- a/frontend/src/components/App.jsx +++ b/frontend/src/components/App.jsx @@ -1,23 +1,11 @@ // App.jsx -import React, { useState } from "react"; -import Navbar from "./Navbar"; +import React from "react"; import ServerList from "./ServerList"; -import Header from "./Header"; -import Stats from "./Stats"; function App() { - const [searchValue, setSearchValue] = useState(""); - - const handleSearchChange = (value) => { - setSearchValue(value); - }; - return (
- -
- - +
); } diff --git a/frontend/src/components/Header.jsx b/frontend/src/components/Header.jsx deleted file mode 100644 index c7e76d4..0000000 --- a/frontend/src/components/Header.jsx +++ /dev/null @@ -1,36 +0,0 @@ -import React from "react"; -import { CContainer, CRow, CCol, CForm, CFormInput } from "@coreui/react"; -import { cilStorage } from "@coreui/icons"; -import CIcon from "@coreui/icons-react"; - -const Header = (props) => { - return ( - - - -

- - FiveM Insights -

-

- Unlock the secrets of FiveM servers with detailed insights and - analytics. Discover more about your favorite servers than ever - before. -

- - - props.onSearchChange && props.onSearchChange(e.target.value) - } - /> - -
-
-
- ); -}; - -export default Header; diff --git a/frontend/src/components/Navbar.jsx b/frontend/src/components/Navbar.jsx deleted file mode 100644 index b089cf9..0000000 --- a/frontend/src/components/Navbar.jsx +++ /dev/null @@ -1,27 +0,0 @@ -// Navbar.jsx -import React from "react"; -import { CContainer, CNavbar, CNavbarBrand } from "@coreui/react"; -import CIcon from "@coreui/icons-react"; -import { cilStorage } from "@coreui/icons"; -import { useNavigate } from "react-router-dom"; - -const Navbar = () => { - const navigate = useNavigate(); - - return ( -
- - - navigate("/")} - style={{ cursor: "pointer" }}> - - FiveM Insights - - - -
- ); -}; - -export default Navbar; diff --git a/frontend/src/components/Server.jsx b/frontend/src/components/Server.jsx deleted file mode 100644 index 879b566..0000000 --- a/frontend/src/components/Server.jsx +++ /dev/null @@ -1,57 +0,0 @@ -import React, { useState, useEffect } from "react"; -import { useParams } from "react-router-dom"; - -import axios from "axios"; - -import Navbar from "./Navbar"; -import Loading from "./utils/Loading"; -import ServerInfo from "./ServerInfo"; -import ServerChart from "./ServerChart"; - -const Server = () => { - const { id } = useParams(); - const [data, setData] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(""); - - useEffect(() => { - const fetchData = async () => { - try { - const data = await axios.get( - `${process.env.REACT_APP_API_URL}server/${id}` - ); - - if (data.status === 200) { - const response = data.data; - - setData(response); - setLoading(false); - } else { - setError("Error fetching data.Data"); - } - } catch (error) { - setError(error); - setLoading(false); - } - }; - - fetchData(); - }, [id]); - - return ( -
- {loading ? ( - - ) : error ? ( -
{error}
- ) : ( -
- - -
- )} -
- ); -}; - -export default Server; diff --git a/frontend/src/components/ServerInfo.jsx b/frontend/src/components/ServerInfo.jsx deleted file mode 100644 index 4497693..0000000 --- a/frontend/src/components/ServerInfo.jsx +++ /dev/null @@ -1,183 +0,0 @@ -import { - CContainer, - CRow, - CCol, - CImage, - CCard, - CCardBody, - CCardTitle, - CCardText, - CButton, - CProgress, -} from "@coreui/react"; -import CIcon from "@coreui/icons-react"; -import { - cilUser, - cilClock, - cilTerminal, - cilUserPlus, - cilChevronDoubleUp, - cilChevronTop, - cilVideogame, - cilCheckCircle, - cilStorage, - cilMonitor, -} from "@coreui/icons"; -import { formatHostname, getColorForPercentage } from "../helpers"; -import { formatDate, calculatePercentage } from "../helpers"; -import ServerChart from "./ServerChart"; - -export function getServerIconURL(joinId, iconVersion) { - if (joinId && typeof iconVersion === "number") { - return `https://servers-frontend.fivem.net/api/servers/icon/${joinId}/${iconVersion}.png`; - } -} - -const ServerInfo = ({ server }) => { - if (!server) return null; - - const data = server; - console.log(data); - - return ( - - - - - -
-
- - -
- -
- - - Players: {data.playersCurrent}/ - {data.playersMax} - - - - Join Server - -
- - {/* Show on mobile devices */} -
- - - Upvote Power: {data.upvotePower} - - - - Burst Power: {data.burstPower} - -
-
- - {/* Show on large devices */} -
- - - Upvote Power: {data.upvotePower} - - - - Burst Power: {data.burstPower} - -
- -
- - - Game Type: {data.gametype} - - - - Map Name: {data.mapname} - - - - Server Version: {data.server} - - - - - IP: {data.connectEndPoints[0]} - - - - Supported? - {data.support_status === "supported" ? ( - Supported - ) : ( - Not Supported - )} - - - - Players: (total: {data.playersCurrent}): -
- {data.players.map((player) => ( - {player.name} - ))} -
- - - - -
-
-
-
-
-
- ); -}; - -export default ServerInfo; diff --git a/frontend/src/components/ServerList.jsx b/frontend/src/components/ServerList.jsx index 89effad..cc52549 100644 --- a/frontend/src/components/ServerList.jsx +++ b/frontend/src/components/ServerList.jsx @@ -1,38 +1,32 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useState, useCallback, useRef } from "react"; import axios from "axios"; import { useNavigate } from "react-router-dom"; import Loading from "./utils/Loading"; -import { formatHostname } from "../helpers"; - -import { - CContainer, - CImage, - CCard, - CCardBody, - CCardTitle, - CCardText, - CCol, - CRow, -} from "@coreui/react"; - -export function getServerIconURL(joinId, iconVersion) { - if (joinId && typeof iconVersion === "number") { - return `https://servers-frontend.fivem.net/api/servers/icon/${joinId}/${iconVersion}.png`; - } -} - -const ServerList = ({ searchValue }) => { +import { removeColors } from "../helpers"; +import { FaUserGroup } from "react-icons/fa6"; +import { FaStar } from "react-icons/fa6"; +import { MdStorage } from "react-icons/md"; + +import { getServerIconURL } from "../helpers"; + +const ServerList = () => { const navigate = useNavigate(); + const [searchValue, onSearchChange] = useState(""); const [data, setData] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); const [filteredData, setFilteredData] = useState([]); + const [visibleData, setVisibleData] = useState([]); // Data visible on the screen + const [page, setPage] = useState(1); // Track current page + const observerRef = useRef(); + + const ITEMS_PER_PAGE = 20; // Number of items to load per page useEffect(() => { const fetchData = async () => { try { const response = await axios.get( - process.env.REACT_APP_API_URL + "servers" + process.env.REACT_APP_API_URL + "api/servers" ); if (response.status !== 200) { @@ -40,6 +34,8 @@ const ServerList = ({ searchValue }) => { } setData(response.data); + setFilteredData(response.data); // Initially show all data + setVisibleData(response.data.slice(0, ITEMS_PER_PAGE)); // Show first page setLoading(false); } catch (error) { setError(error.message); @@ -55,59 +51,177 @@ const ServerList = ({ searchValue }) => { const filtered = data.filter((item) => item.hostname.toLowerCase().includes(searchValue.toLowerCase()) ); + + filtered.forEach((item) => { + item.hostname = removeColors(item.hostname); + item.projectName = removeColors(item.projectName); + + item.hostname = item.hostname.slice(0, 120); + item.projectName = item.projectName.slice(0, 120); + }); + setFilteredData(filtered); + setPage(1); // Reset to the first page when filtering + setVisibleData(filtered.slice(0, ITEMS_PER_PAGE)); // Reset visible data }, [searchValue, data]); + // Load more data when reaching the bottom + const loadMoreData = useCallback(() => { + const nextPage = page + 1; + const newVisibleData = filteredData.slice(0, nextPage * ITEMS_PER_PAGE); + + setVisibleData(newVisibleData); + setPage(nextPage); + }, [filteredData, page]); + + useEffect(() => { + if (observerRef.current) observerRef.current.disconnect(); + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting) { + loadMoreData(); + } + }, + { threshold: 1.0 } + ); + + const target = document.querySelector("#load-more"); + if (target) observer.observe(target); + observerRef.current = observer; + + return () => { + if (observerRef.current) observerRef.current.disconnect(); + }; + }, [loadMoreData]); + const handleCardClick = (id) => { navigate(`/server/${id}`); }; return ( -
- -

Server List ({filteredData.length})

+
+
+
+

+ + FiveM Insights +

+

+ Unlock the secrets of FiveM servers with detailed insights and + analytics. Discover more about your favorite servers than ever + before. +

+
+ {/* Search Input */} + onSearchChange(e.target.value)} + /> + + {/* Dropdown Menu for Filters */} + + + {/* Reset Filter Button */} + +
+
+ {loading ? ( ) : error ? ( -
{error}
+
{error}
) : ( - filteredData.map((item, index) => ( - - - handleCardClick(item.id)} - style={{ cursor: "pointer", flexDirection: "row" }}> - - - +
+ {visibleData.map((item, index) => ( +
+
handleCardClick(item.id)}> + Server Icon - - Game Type: {item.gametype} - - - Map Name: {item.mapname} - - - Players: {item.playersCurrent}/ - {item.playersMax} - - - - - - )) +
+ {/* Text Section */} +
+

+ {item.projectName} +

+

+ {item.hostname} +

+
+ + {/* Right Section */} +
+

+ + {item.rank} +

+

+ + {item.playersCurrent} +

+
+
+
+
+ ))} +
+
+ )} - +
); }; diff --git a/frontend/src/components/Stats.jsx b/frontend/src/components/Stats.jsx index 8dbd699..46bfc1a 100644 --- a/frontend/src/components/Stats.jsx +++ b/frontend/src/components/Stats.jsx @@ -1,11 +1,6 @@ import React from "react"; -import { CContainer, CRow, CCol } from "@coreui/react"; -import CIcon from "@coreui/icons-react"; -import { cilStorage, cilUserPlus } from "@coreui/icons"; -import { CWidgetStatsC } from "@coreui/react"; import axios from "axios"; import { useEffect, useState } from "react"; -import Loading from "./utils/Loading"; const Stats = () => { const [data, setData] = useState([]); @@ -43,34 +38,35 @@ const Stats = () => { ) : error ? (
{error}
) : ( - - - - } - title="Online Servers" - value={data.servers} - /> - - - } - color="primary" - inverse - // calculate percentage bar value. data.maxPlayers and data.players - progress={{ - value: percentage, - }} - title="Online Players" - value={percentage + "%"} - /> - - - +
test
+ // + // + // + // } + // title="Online Servers" + // value={data.servers} + // /> + // + // + // } + // color="primary" + // inverse + // // calculate percentage bar value. data.maxPlayers and data.players + // progress={{ + // value: percentage, + // }} + // title="Online Players" + // value={percentage + "%"} + // /> + // + // + // )}
); diff --git a/frontend/src/components/ServerChart.jsx b/frontend/src/components/server/ServerChart.jsx similarity index 70% rename from frontend/src/components/ServerChart.jsx rename to frontend/src/components/server/ServerChart.jsx index f5429eb..8c7103c 100644 --- a/frontend/src/components/ServerChart.jsx +++ b/frontend/src/components/server/ServerChart.jsx @@ -1,11 +1,10 @@ -import { CCard, CContainer } from "@coreui/react"; import axios from "axios"; import React, { useEffect, useState } from "react"; -import Loading from "./utils/Loading"; -import { ChartComponent } from "./utils/Chart"; +import Loading from "../utils/Loading"; +import { ChartComponent } from "../utils/Chart"; -const ServerChart = ({ server }) => { - const id = server.id; +const ServerChart = () => { + const id = window.location.pathname.split("/")[2]; const [chartData, setChartData] = useState(null); const [loading, setLoading] = useState(true); @@ -15,7 +14,7 @@ const ServerChart = ({ server }) => { useEffect(() => { const fetchData = async () => { try { - const url = `${process.env.REACT_APP_API_URL}server/${id}/history/${period}`; + const url = `${process.env.REACT_APP_API_URL}api/server/${id}/history/${period}`; const data = await axios.get(url); if (data.status === 200) { @@ -27,7 +26,7 @@ const ServerChart = ({ server }) => { setError("Error fetching history data"); } } catch (error) { - setError(error); + setError(error.message || "An unknown error occurred"); setLoading(false); } }; @@ -58,13 +57,7 @@ const ServerChart = ({ server }) => { ) : error ? (
{error}
) : ( -
- - - - - -
+ )} ); diff --git a/frontend/src/components/server/Show.jsx b/frontend/src/components/server/Show.jsx new file mode 100644 index 0000000..16b47d2 --- /dev/null +++ b/frontend/src/components/server/Show.jsx @@ -0,0 +1,104 @@ +import React, { useEffect, useState } from "react"; +import axios from "axios"; +import { getServerIconURL, removeColors } from "../../helpers"; +import { FaArrowLeft } from "react-icons/fa"; +import ServerChart from "./ServerChart"; + +const Server = () => { + // get id from path: "/server/:id", browser router + const id = window.location.pathname.split("/")[2]; + + const [data, setData] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + + useEffect(() => { + const fetchData = async () => { + try { + const response = await axios.get( + `${process.env.REACT_APP_API_URL}api/server/${id}` + ); + + if (response.status !== 200) { + throw new Error("Error fetching data"); + } + + const newData = response.data; + + newData.tags = JSON.parse(newData.tags); + newData.hostname = removeColors(newData.hostname); + newData.projectName = removeColors(newData.projectName); + + setData(response.data); + setLoading(false); + } catch (error) { + setError(error.message); + setLoading(false); + } + }; + + fetchData(); + }, []); + + return ( +
+
+ + Server Banner +
+
+ Server Icon +
+ +
+
+

{data.projectName}

+

{data.projectDescription}

+
+
+ {Array.isArray(data.tags) ? ( + data.tags.map((tag, index) => ( + + {tag} + + )) + ) : ( + + )} +
+
+
+ +
+
+

Resources ({data.resources?.length})

+
+
+

Players ({data.players?.length})

+
+
+ +
+ +
+
+
+ ); +}; + +export default Server; diff --git a/frontend/src/components/utils/Loading.jsx b/frontend/src/components/utils/Loading.jsx index 8f7b5c3..1d84370 100644 --- a/frontend/src/components/utils/Loading.jsx +++ b/frontend/src/components/utils/Loading.jsx @@ -1,11 +1,27 @@ import React, { Component } from "react"; -import { CRow, CSpinner } from "@coreui/react"; class Loading extends Component { render() { return ( -
- +
+
+ + Loading... +
); } diff --git a/frontend/src/helpers/index.jsx b/frontend/src/helpers/index.jsx index e3a2ad4..6d0461c 100644 --- a/frontend/src/helpers/index.jsx +++ b/frontend/src/helpers/index.jsx @@ -43,6 +43,10 @@ export const formatHostname = (hostname) => { return formattedHostname; }; +export const removeColors = (text) => { + return text.replace(/\^\d/g, ""); +}; + export const formatDate = (dateString) => { const date = new Date(dateString); return date.toLocaleString(); @@ -55,3 +59,9 @@ export const calculatePercentage = (clients, maxClients) => { export const getColorForPercentage = (percentage) => { return percentage < 50 ? "success" : percentage < 90 ? "warning" : "danger"; }; + +export function getServerIconURL(joinId, iconVersion) { + if (joinId && typeof iconVersion === "number") { + return `https://servers-frontend.fivem.net/api/servers/icon/${joinId}/${iconVersion}.png`; + } +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 8683712..6de48da 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,9 +1,36 @@ -@import url("https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap"); +@tailwind base; -* { - font-family: "Poppins", sans-serif; +@layer base { + html, + body { + @apply bg-gray-800; + width: 100%; + height: 100%; + } } -#hostname { - word-break: break-word; +@tailwind components; +@tailwind utilities; + +body, +html { + overflow-x: hidden; +} + +::-webkit-scrollbar { + width: 0.25rem; +} + +::-webkit-scrollbar-thumb { + background-color: #4a4a4a; + border-radius: 4px; +} + +::-webkit-scrollbar-track { + background: #1a1a1a; + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background-color: #787878; } diff --git a/frontend/src/index.js b/frontend/src/index.js index a7c06d7..4fe95a8 100644 --- a/frontend/src/index.js +++ b/frontend/src/index.js @@ -1,11 +1,18 @@ import React from "react"; import ReactDOM from "react-dom/client"; -// Import Bootstrap CSS -import "./index.css"; +import "@fontsource/poppins/100.css"; +import "@fontsource/poppins/200.css"; +import "@fontsource/poppins/300.css"; +import "@fontsource/poppins/400.css"; +import "@fontsource/poppins/500.css"; +import "@fontsource/poppins/600.css"; +import "@fontsource/poppins/700.css"; +import "@fontsource/poppins/800.css"; +import "@fontsource/poppins/900.css"; -import "bootstrap/dist/css/bootstrap.min.css"; -import "@coreui/coreui/dist/css/coreui.min.css"; +// Import CSS +import "./index.css"; // Router import { RouterProvider } from "react-router-dom"; diff --git a/frontend/src/router/index.jsx b/frontend/src/router/index.jsx index b7c7e8c..cb35a56 100644 --- a/frontend/src/router/index.jsx +++ b/frontend/src/router/index.jsx @@ -1,8 +1,8 @@ -import { createHashRouter } from "react-router-dom"; +import { createBrowserRouter } from "react-router-dom"; import App from "../components/App"; -import Server from "../components/Server"; +import Server from "../components/server/Show"; -const router = createHashRouter([ +const router = createBrowserRouter([ { path: "/", element: , diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..f10ed3b --- /dev/null +++ b/frontend/tailwind.config.js @@ -0,0 +1,15 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: ["./src/**/*.{js,jsx,ts,tsx}"], + theme: { + extend: { + fontFamily: { + sans: ["Poppins", "sans-serif"], + }, + height: { + 132: "34rem", + }, + }, + }, + plugins: [], +};