Initial commit
This commit is contained in:
commit
9b2368f588
26 changed files with 5272 additions and 0 deletions
23
.gitignore
vendored
Normal file
23
.gitignore
vendored
Normal 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
1
.npmrc
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
engine-strict=true
|
||||||
6
.prettierignore
Normal file
6
.prettierignore
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
# Package Managers
|
||||||
|
package-lock.json
|
||||||
|
pnpm-lock.yaml
|
||||||
|
yarn.lock
|
||||||
|
bun.lock
|
||||||
|
bun.lockb
|
||||||
15
.prettierrc
Normal file
15
.prettierrc
Normal 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
38
README.md
Normal 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
36
eslint.config.js
Normal 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
4588
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
47
package.json
Normal file
47
package.json
Normal 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
7
src/app.css
Normal 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
13
src/app.d.ts
vendored
Normal 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
14
src/app.html
Normal 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>
|
||||||
21
src/lib/data/meta/fetcher.ts
Normal file
21
src/lib/data/meta/fetcher.ts
Normal 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 };
|
||||||
10
src/lib/data/meta/types.ts
Normal file
10
src/lib/data/meta/types.ts
Normal 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 };
|
||||||
122
src/lib/data/server/chart.ts
Normal file
122
src/lib/data/server/chart.ts
Normal 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 };
|
||||||
31
src/lib/data/server/fetcher.ts
Normal file
31
src/lib/data/server/fetcher.ts
Normal 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 };
|
||||||
8
src/lib/data/server/types.ts
Normal file
8
src/lib/data/server/types.ts
Normal 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
1
src/lib/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
// place files you want to import through the `$lib` alias in this folder.
|
||||||
61
src/lib/numbers.ts
Normal file
61
src/lib/numbers.ts
Normal 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
13
src/routes/+layout.svelte
Normal 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()}
|
||||||
21
src/routes/plugin/+error.svelte
Normal file
21
src/routes/plugin/+error.svelte
Normal 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>
|
||||||
127
src/routes/plugin/+page.svelte
Normal file
127
src/routes/plugin/+page.svelte
Normal 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>
|
||||||
30
src/routes/plugin/+page.ts
Normal file
30
src/routes/plugin/+page.ts
Normal 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
BIN
static/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
13
svelte.config.js
Normal file
13
svelte.config.js
Normal 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
19
tsconfig.json
Normal 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
7
vite.config.ts
Normal 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()]
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue