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