Initial commit

This commit is contained in:
Minecon724 2025-06-06 17:19:25 +02:00
commit 9b2368f588
Signed by: Minecon724
GPG key ID: A02E6E67AB961189
26 changed files with 5272 additions and 0 deletions

23
.gitignore vendored Normal file
View file

@ -0,0 +1,23 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

1
.npmrc Normal file
View file

@ -0,0 +1 @@
engine-strict=true

6
.prettierignore Normal file
View file

@ -0,0 +1,6 @@
# Package Managers
package-lock.json
pnpm-lock.yaml
yarn.lock
bun.lock
bun.lockb

15
.prettierrc Normal file
View file

@ -0,0 +1,15 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
]
}

38
README.md Normal file
View file

@ -0,0 +1,38 @@
# sv
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```bash
# create a new project in the current directory
npx sv create
# create a new project in mstats-webui
npx sv create mstats-webui
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```bash
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```bash
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.

36
eslint.config.js Normal file
View file

@ -0,0 +1,36 @@
import prettier from 'eslint-config-prettier';
import js from '@eslint/js';
import { includeIgnoreFile } from '@eslint/compat';
import svelte from 'eslint-plugin-svelte';
import globals from 'globals';
import { fileURLToPath } from 'node:url';
import ts from 'typescript-eslint';
import svelteConfig from './svelte.config.js';
const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
export default ts.config(
includeIgnoreFile(gitignorePath),
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs.recommended,
prettier,
...svelte.configs.prettier,
{
languageOptions: {
globals: { ...globals.browser, ...globals.node }
},
rules: { 'no-undef': 'off' }
},
{
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
languageOptions: {
parserOptions: {
projectService: true,
extraFileExtensions: ['.svelte'],
parser: ts.parser,
svelteConfig
}
}
}
);

4588
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

47
package.json Normal file
View file

@ -0,0 +1,47 @@
{
"name": "mstats-webui",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --write .",
"lint": "prettier --check . && eslint ."
},
"devDependencies": {
"@eslint/compat": "^1.2.5",
"@eslint/js": "^9.18.0",
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.16.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/forms": "^0.5.9",
"@tailwindcss/typography": "^0.5.15",
"@tailwindcss/vite": "^4.0.0",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-svelte": "^3.0.0",
"globals": "^16.0.0",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.11",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.0.0",
"typescript-eslint": "^8.20.0",
"vite": "^6.2.6"
},
"dependencies": {
"@steeze-ui/heroicons": "^2.4.2",
"@steeze-ui/solid-icon": "^1.1.0",
"@steeze-ui/svelte-icon": "^1.6.2",
"apexcharts": "^4.7.0",
"flowbite": "^3.1.2",
"flowbite-svelte": "^1.5.5"
}
}

7
src/app.css Normal file
View file

@ -0,0 +1,7 @@
@import 'tailwindcss';
@plugin '@tailwindcss/forms';
@plugin '@tailwindcss/typography';
@import "flowbite/src/themes/default";
@plugin "flowbite/plugin";
@source "../node_modules/flowbite";

13
src/app.d.ts vendored Normal file
View file

@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

14
src/app.html Normal file
View file

@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover" class="bg-gray-950 text-white dark"></body>
<div class="container font-Nunito mx-auto px-4 mt-[10vh]">
%sveltekit.body%
</div>
</body>
</html>

View file

@ -0,0 +1,21 @@
import type { PluginMetadata } from "$lib/data/meta/types";
async function fetchPluginMetadata(fetch: (input: RequestInfo, init?: RequestInit) => Promise<Response>, pluginId: number): Promise<PluginMetadata> {
const response = await fetch(`https://bstats.org/api/v1/plugins/${pluginId}`);
if (!response.ok) {
throw new Error("Failed to fetch plugin metadata: " + response.statusText);
}
const rawJsonData = await response.json();
console.debug("Raw JSON plguin metadata:", rawJsonData);
return {
id: rawJsonData.id,
name: rawJsonData.name,
author: rawJsonData.owner.name
};
}
export { fetchPluginMetadata };

View file

@ -0,0 +1,10 @@
type PluginMetadata = {
/** The unique identifier for the plugin */
id: number;
/** The name of the plugin */
name: string;
/** The author of the plugin */
author: string;
}
export type { PluginMetadata };

View file

@ -0,0 +1,122 @@
import ApexCharts, { type ApexOptions } from "apexcharts";
import type { ServerCountDataSegment } from "./types";
import { getReadableDuration } from "$lib/numbers";
type DataDurationSelection = {
label: string;
seconds: number;
}
function getDataDurations(dataScopeSeconds: number): DataDurationSelection[] {
const chartTimeSelect: DataDurationSelection[] = [];
if (dataScopeSeconds > 24 * 3600) {
chartTimeSelect.push({ label: '1 day', seconds: 24 * 3600 });
}
if (dataScopeSeconds > 7 * 24 * 3600) {
chartTimeSelect.push({ label: '1 week', seconds: 7 * 24 * 3600 });
}
if (dataScopeSeconds > 30 * 24 * 3600) {
chartTimeSelect.push({ label: '1 month', seconds: 30 * 24 * 3600 });
}
if (dataScopeSeconds > 90 * 24 * 3600) {
chartTimeSelect.push({ label: '3 months', seconds: 90 * 24 * 3600 });
}
if (dataScopeSeconds > 180 * 24 * 3600) {
chartTimeSelect.push({ label: '6 months', seconds: 180 * 24 * 3600 });
}
if (dataScopeSeconds > 365 * 24 * 3600) {
chartTimeSelect.push({ label: '1 year', seconds: 365 * 24 * 3600 });
}
chartTimeSelect.push({ label: 'All time', seconds: -1 });
return chartTimeSelect;
}
function renderChart(chartElement: HTMLElement, onZoomed: (fromSegment: number, toSegment: number) => undefined, pluginName: string, segments: ServerCountDataSegment[]): void {
const data = segments.map(segment => ({
x: segment.date.toLocaleString(),
y: segment.count,
}));
const defaultMax = data[data.length - 1].x;
const defaultMin = data[0].x;
console.debug("Rendering chart with data:", data);
const options: ApexOptions = {
chart: {
height: "100%",
width: "100%",
type: "area",
dropShadow: {
enabled: false,
},
toolbar: {
show: false
},
events: {
zoomed: (chartContext, { xaxis }) => {
onZoomed(xaxis.min, xaxis.max);
}
}
},
tooltip: {
enabled: true,
x: {
show: false,
},
followCursor: true,
},
fill: {
type: "gradient",
gradient: {
opacityFrom: 0.5,
opacityTo: 0,
},
},
dataLabels: {
enabled: false,
},
stroke: {
width: 6,
},
grid: {
show: false,
},
series: [
{
name: "Servers using " + pluginName,
data: data,
},
],
xaxis: {
labels: {
show: false,
},
axisBorder: {
show: false,
},
axisTicks: {
show: false,
},
min: defaultMin,
max: defaultMax,
},
yaxis: {
show: false
},
};
const chart = new ApexCharts(chartElement, options);
chart.render();
}
export { renderChart };

View file

@ -0,0 +1,31 @@
import type { ServerCountDataSegment } from "./types";
const DATA_SEGMENT_DURATION_SECONDS = 1800; // Duration of each data segment in seconds
async function fetchChartData(fetch: (input: RequestInfo, init?: RequestInit) => Promise<Response>, pluginId: number, durationSeconds: number): Promise<ServerCountDataSegment[]> {
// fun fact: the default on bstats.org is 1488
const maxElements = Math.ceil(durationSeconds / DATA_SEGMENT_DURATION_SECONDS);
const response = await fetch(`https://bstats.org/api/v1/plugins/${pluginId}/charts/servers/data/?maxElements=${maxElements}`);
if (!response.ok) {
throw new Error("Failed to fetch chart data: " + response.statusText);
}
const rawJsonData = await response.json();
if (!Array.isArray(rawJsonData)) {
throw new Error("Invalid data format received from API (expected an array, but got " + typeof rawJsonData + ")");
}
console.debug("Raw JSON data:", rawJsonData);
const segments: ServerCountDataSegment[] = rawJsonData.map(item => ({
date: new Date(item[0]),
count: item[1],
}));
return segments;
}
export { fetchChartData };

View file

@ -0,0 +1,8 @@
type ServerCountDataSegment = {
/** The start of the segment */
date: Date;
/** Number of servers at that timestamp */
count: number;
};
export type { ServerCountDataSegment };

1
src/lib/index.ts Normal file
View file

@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

61
src/lib/numbers.ts Normal file
View file

@ -0,0 +1,61 @@
const SECONDS_IN = {
YEAR: 31536000,
MONTH: 2592000,
WEEK: 604800,
DAY: 86400,
HOUR: 3600,
MINUTE: 60,
};
function getAverage(values: number[]): number {
const total = values.reduce((sum, value) => sum + value, 0);
return Math.floor(total / values.length);
}
function minifyThousands(value: number): string {
if (value >= 1000) {
return (value / 1000).toFixed() + "k";
}
if (value >= 10000) {
return (value / 1000).toFixed(1) + "k";
}
if (value >= 1000) {
return (value / 1000).toFixed(2) + "k";
}
return value.toString();
}
function getAverageWithThousandsMinified(values: number[]): string {
const average = getAverage(values);
return minifyThousands(average);
}
function getReadableDuration(durationSeconds: number): string {
const timeDefinitions = [
{ seconds: SECONDS_IN.YEAR, singular: "year", plural: "years" },
{ seconds: SECONDS_IN.MONTH, singular: "month", plural: "months" },
{ seconds: SECONDS_IN.WEEK, singular: "week", plural: "weeks" },
{ seconds: SECONDS_IN.DAY, singular: "day", plural: "days" },
{ seconds: SECONDS_IN.HOUR, singular: "hour", plural: "hours" },
];
for (const unit of timeDefinitions) {
if (durationSeconds >= 2 * unit.seconds) {
return `${Math.ceil(durationSeconds / unit.seconds)} ${unit.plural}`;
}
if (durationSeconds >= unit.seconds) {
return `1 ${unit.singular}`;
}
}
return "30 minutes"; // minimum zoom level
}
export { getAverage, minifyThousands, getAverageWithThousandsMinified, getReadableDuration };

13
src/routes/+layout.svelte Normal file
View file

@ -0,0 +1,13 @@
<script lang="ts">
import { onMount } from 'svelte';
import '../app.css';
import { initFlowbite } from 'flowbite';
let { children } = $props();
onMount(() => {
initFlowbite();
});
</script>
{@render children()}

View file

@ -0,0 +1,21 @@
<script lang="ts">
import { Icon } from "@steeze-ui/svelte-icon";
import { ExclamationCircle } from "@steeze-ui/heroicons";
import { page } from "$app/state";
let errorText = "An unknown error occurred. Please try again later.";
if (page.error?.message) {
errorText = page.error.message;
}
</script>
<div class="flex flex-col justify-between gap-3 p-4 mb-[10vh]">
<h1 class="text-6xl font-light text-gray-100">Error</h1>
<div class="flex items-center gap-2 text-gray-500 ms-1 text-lg">
<Icon src={ExclamationCircle} theme="solid" size="1em"></Icon>
<span>{errorText}</span>
</div> <!-- TODO this looks eh -->
</div>

View file

@ -0,0 +1,127 @@
<script lang="ts">
import { Icon } from "@steeze-ui/svelte-icon";
import { UserCircle, ArrowUp, ArrowDown, ChevronDown } from "@steeze-ui/heroicons";
import { onMount } from "svelte";
import { browser } from "$app/environment";
import { page } from "$app/state";
import { getAverage, getAverageWithThousandsMinified, getReadableDuration, minifyThousands } from "$lib/numbers";
import type { PageData } from "./$types";
export let data: PageData;
let serversThisWeek = 0;
let improvement = 0;
let selectedPeriod = "Last 7 days";
onMount(async () => {
if (browser) {
const renderChart = (await import("$lib/data/server/chart")).renderChart;
renderChart(
document.getElementById("area-chart") as HTMLElement,
(fromSegment, toSegment) => {
let label = getReadableDuration((toSegment - fromSegment) * 1800); // magic number 1800 is the duration of each data segment in seconds
if ((data.segments.length - toSegment) * 1800 < 3600 * 2) { // last 2 hours are considered "last"
label = "Last " + label;
} else if ((toSegment - fromSegment) * 1800 < 24 * 3600) {
label = label + " on " + data.segments[fromSegment].date.toLocaleDateString();
} else {
label = label + " starting " + data.segments[fromSegment].date.toLocaleDateString();
}
document.getElementById("dropdownDefaultButton")!.innerText = label;
},
data.metadata.name,
data.segments
);
const segments = data.segments;
const thisWeekDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
const lastWeekDate = new Date(Date.now() - 2 * 7 * 24 * 60 * 60 * 1000);
serversThisWeek = getAverage(segments.filter(segment => segment.date >= thisWeekDate).map(segment => segment.count));
let serversLastWeek = getAverage(segments.filter(segment => segment.date < thisWeekDate && segment.date >= lastWeekDate).map(segment => segment.count));
improvement = (serversThisWeek - serversLastWeek) / serversLastWeek
console.debug("Servers this week:", serversThisWeek);
console.debug("Servers last week:", serversLastWeek);
console.debug("Improvement:", improvement);
}
});
</script>
<div class="flex flex-col justify-between gap-3 p-4 mb-[10vh]">
<h1 class="text-6xl font-light text-gray-100">{page.data.metadata.name}</h1>
<div class="flex items-center gap-2 text-gray-500 ms-1 text-lg">
<Icon src={UserCircle} theme="solid" size="1em"></Icon>
<span>{page.data.metadata.author}</span>
</div> <!-- TODO this looks eh -->
</div>
<div class="flex flex-wrap gap-4">
<div class="w-full max-w-1/2 bg-white rounded-lg shadow-sm dark:bg-gray-800 p-4 md:p-6">
<div class="flex justify-between">
<div>
<h5 id="" class="leading-none text-3xl font-bold text-gray-900 dark:text-white pb-2">{minifyThousands(serversThisWeek)}</h5>
<p class="text-base font-normal text-gray-500 dark:text-gray-400">Servers this week</p>
</div>
<div class="flex items-center px-2.5 py-0.5 text-base font-semibold {improvement > 0 ? 'text-green-500 dark:text-green-500' : 'text-red-500 dark:text-red-500'} text-center">
{Math.abs(improvement * 100).toFixed(2)}%
{#if improvement > 0}
<Icon src={ArrowUp} theme="solid" size="1em"></Icon>
{:else}
<Icon src={ArrowDown} theme="solid" size="1em"></Icon>
{/if}
</div>
</div>
<div id="area-chart"></div>
<div class="grid grid-cols-1 items-center border-gray-200 border-t dark:border-gray-700 justify-between">
<div class="flex justify-between items-center pt-5">
<!-- Button -->
<button
id="dropdownDefaultButton"
data-dropdown-toggle="lastDaysdropdown"
data-dropdown-placement="bottom"
class="text-sm font-medium text-gray-500 dark:text-gray-400 hover:text-gray-900 text-center inline-flex items-center dark:hover:text-white cursor-pointer"
type="button">
<span id="dropdownButtonLabel">{selectedPeriod}</span>
<Icon src={ChevronDown} theme="solid" size="1em"></Icon>
</button>
<!-- Dropdown menu -->
<div id="lastDaysdropdown" class="z-10 hidden bg-white divide-y divide-gray-100 rounded-lg shadow-sm w-44 dark:bg-gray-700">
<ul class="py-2 text-sm text-gray-700 dark:text-gray-200" aria-labelledby="dropdownDefaultButton">
<li>
<a href="#" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Yesterday</a>
</li>
<li>
<a href="#" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Today</a>
</li>
<li>
<a href="#" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Last 7 days</a>
</li>
<li>
<a href="#" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Last 30 days</a>
</li>
<li>
<a href="#" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Last 90 days</a>
</li>
</ul>
</div>
<!--<a
href="#"
class="uppercase text-sm font-semibold inline-flex items-center rounded-lg text-blue-600 hover:text-blue-700 dark:hover:text-blue-500 hover:bg-gray-100 dark:hover:bg-gray-700 dark:focus:ring-gray-700 dark:border-gray-700 px-3 py-2">
Users Report
<svg class="w-2.5 h-2.5 ms-1.5 rtl:rotate-180" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 6 10">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 9 4-4-4-4"/>
</svg>
</a>-->
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,30 @@
import { fetchPluginMetadata } from "$lib/data/meta/fetcher";
import type { PluginMetadata } from "$lib/data/meta/types";
import { fetchChartData } from "$lib/data/server/fetcher";
import type { ServerCountDataSegment } from "$lib/data/server/types";
import { error } from "@sveltejs/kit";
import type { PageLoad } from "./$types";
export interface PageData {
metadata: PluginMetadata;
segments: ServerCountDataSegment[];
}
export const load: PageLoad = async ({ fetch, url }) => {
if (!url.searchParams.has("id")) {
throw error(400, "Missing plugin ID in the URL query parameters.");
}
const pluginId = parseInt(url.searchParams.get("id") || "0");
const metadata = await fetchPluginMetadata(fetch, pluginId);
console.debug("Loaded metadata: ", metadata);
const segments = await fetchChartData(fetch, pluginId, 14 * 24 * 3600); // 14 to get improvement over this week
console.debug("Loaded segments: ", segments);
return {
metadata,
segments,
} as PageData;
}

BIN
static/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

13
svelte.config.js Normal file
View file

@ -0,0 +1,13 @@
import adapter from '@sveltejs/adapter-static';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter({
strict: false,
}),
}
};
export default config;

19
tsconfig.json Normal file
View file

@ -0,0 +1,19 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
},
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
// from the referenced tsconfig.json - TypeScript does not merge them in
}

7
vite.config.ts Normal file
View file

@ -0,0 +1,7 @@
import tailwindcss from '@tailwindcss/vite';
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [tailwindcss(), sveltekit()]
});