From c7efd759a857dd13bdb73c0654450d230b92ea22 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 09:12:59 +0000 Subject: [PATCH] Panel web: servers, capes, player profiles, auth server settings - Servers: live status, players and TPS; add a server and get its token with copy-paste setup for the plugin/mod and authlib-injector; detail page with leaderboard (sortable), online players and activity feed - Capes: upload, preview, and choose who can wear each one - Players: click a player for their skin (upload, arm style, reset), cape, playtime and per-server stats; disable with a reason - Settings: Microsoft section replaced by the auth server (public address, Yggdrasil URL, start flag, signing key) - Dashboard: players online now and connected servers - Avatars come from the panel's own skins Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011ARcGWxLx21FwXJ3yfGriS --- panel/server/src/routes/admin.rs | 36 ++- panel/server/src/routes/mod.rs | 1 + panel/web/src/App.svelte | 9 + panel/web/src/components/Avatar.svelte | 17 +- panel/web/src/components/CapePreview.svelte | 30 ++ panel/web/src/components/PlayerPanel.svelte | 153 ++++++++++ panel/web/src/components/ServerForm.svelte | 56 ++++ panel/web/src/components/Sidebar.svelte | 6 +- panel/web/src/components/SkinView.svelte | 101 +++++++ panel/web/src/components/TokenReveal.svelte | 55 ++++ panel/web/src/lib/api.ts | 12 + panel/web/src/lib/types.ts | 64 ++++- panel/web/src/pages/Capes.svelte | 198 +++++++++++++ panel/web/src/pages/Dashboard.svelte | 27 +- panel/web/src/pages/InstanceEditor.svelte | 2 +- panel/web/src/pages/ServerDetail.svelte | 297 ++++++++++++++++++++ panel/web/src/pages/Servers.svelte | 144 ++++++++++ panel/web/src/pages/Settings.svelte | 56 +++- panel/web/src/pages/Users.svelte | 58 +++- 19 files changed, 1284 insertions(+), 38 deletions(-) create mode 100644 panel/web/src/components/CapePreview.svelte create mode 100644 panel/web/src/components/PlayerPanel.svelte create mode 100644 panel/web/src/components/ServerForm.svelte create mode 100644 panel/web/src/components/SkinView.svelte create mode 100644 panel/web/src/components/TokenReveal.svelte create mode 100644 panel/web/src/pages/Capes.svelte create mode 100644 panel/web/src/pages/ServerDetail.svelte create mode 100644 panel/web/src/pages/Servers.svelte diff --git a/panel/server/src/routes/admin.rs b/panel/server/src/routes/admin.rs index d01320e..7aede81 100644 --- a/panel/server/src/routes/admin.rs +++ b/panel/server/src/routes/admin.rs @@ -35,10 +35,15 @@ pub async fn stats(_: AdminUser, State(state): State) -> AppResult, Option, String)> = sqlx::query_as("SELECT instance_id, username, created_at FROM events ORDER BY id DESC LIMIT 12").fetch_all(&state.db).await?; - let players_7d: i64 = sqlx::query_scalar("SELECT COUNT(DISTINCT username) FROM events WHERE created_at >= ?") - .bind((chrono::Utc::now() - chrono::Duration::days(7)).to_rfc3339()) - .fetch_one(&state.db) - .await?; + // Anyone who launched the game or played on a connected server. + let week = (chrono::Utc::now() - chrono::Duration::days(7)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true); + let players_7d: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM (SELECT lower(username) FROM events WHERE username IS NOT NULL AND created_at >= ?1 + UNION SELECT lower(name) FROM player_stats WHERE last_seen >= ?1)", + ) + .bind(&week) + .fetch_one(&state.db) + .await?; // Fill in empty days so the chart is continuous. let mut days = Vec::new(); @@ -707,6 +712,29 @@ pub async fn admin_delete_skin(_: AdminUser, State(state): State, Path Ok(Json(view(&state, user).await?)) } +#[derive(Deserialize)] +pub struct AdminModelInput { + model: String, +} + +pub async fn admin_set_model( + _: AdminUser, + State(state): State, + Path(id): Path, + Json(input): Json, +) -> AppResult> { + if !matches!(input.model.as_str(), "classic" | "slim") { + return Err(AppError::bad_request("model must be classic or slim")); + } + sqlx::query("UPDATE users SET skin_model = ? WHERE id = ?").bind(&input.model).bind(id).execute(&state.db).await?; + let user = sqlx::query_as("SELECT * FROM users WHERE id = ?") + .bind(id) + .fetch_optional(&state.db) + .await? + .ok_or_else(|| AppError::not_found("user not found"))?; + Ok(Json(view(&state, user).await?)) +} + #[derive(Deserialize)] pub struct AdminCapeInput { cape_id: Option, diff --git a/panel/server/src/routes/mod.rs b/panel/server/src/routes/mod.rs index d54c225..3790c8c 100644 --- a/panel/server/src/routes/mod.rs +++ b/panel/server/src/routes/mod.rs @@ -45,6 +45,7 @@ pub fn api(state: &AppState) -> Router { .route("/instances/{id}/files", post(admin::upload_files).delete(admin::delete_file)) .route("/uploads", post(admin::upload_media)) .route("/users/{id}/skin", post(admin::admin_set_skin).delete(admin::admin_delete_skin)) + .route("/users/{id}/skin/model", axum::routing::put(admin::admin_set_model)) .route("/users/{id}/cape", axum::routing::put(admin::admin_set_cape)) .route("/capes", get(admin::list_capes).post(admin::create_cape)) .route("/capes/{id}", axum::routing::put(admin::update_cape).delete(admin::delete_cape)) diff --git a/panel/web/src/App.svelte b/panel/web/src/App.svelte index 420b03c..bbc2c4e 100644 --- a/panel/web/src/App.svelte +++ b/panel/web/src/App.svelte @@ -8,6 +8,9 @@ import Users from './pages/Users.svelte'; import Branding from './pages/Branding.svelte'; import Settings from './pages/Settings.svelte'; + import Servers from './pages/Servers.svelte'; + import ServerDetail from './pages/ServerDetail.svelte'; + import Capes from './pages/Capes.svelte'; import { get } from './lib/api'; import { route } from './lib/router.svelte'; import { logout, session, type Me } from './lib/session.svelte'; @@ -55,6 +58,12 @@ {:else if route.name === 'instances'} + {:else if route.name === 'servers' && route.params[0]} + + {:else if route.name === 'servers'} + + {:else if route.name === 'capes'} + {:else if route.name === 'users'} {:else if route.name === 'branding'} diff --git a/panel/web/src/components/Avatar.svelte b/panel/web/src/components/Avatar.svelte index e1c171c..9f3fa74 100644 --- a/panel/web/src/components/Avatar.svelte +++ b/panel/web/src/components/Avatar.svelte @@ -1,16 +1,23 @@ {#if failed} - {name.slice(0, 1).toUpperCase()} + {name.slice(0, 1).toUpperCase()} {:else} - (failed = true)} /> + (failed = true)} /> {/if} diff --git a/panel/web/src/components/CapePreview.svelte b/panel/web/src/components/CapePreview.svelte new file mode 100644 index 0000000..6de39dc --- /dev/null +++ b/panel/web/src/components/CapePreview.svelte @@ -0,0 +1,30 @@ + + + + + diff --git a/panel/web/src/components/PlayerPanel.svelte b/panel/web/src/components/PlayerPanel.svelte new file mode 100644 index 0000000..b20d778 --- /dev/null +++ b/panel/web/src/components/PlayerPanel.svelte @@ -0,0 +1,153 @@ + + +
+
+
+ + +
+
+ + +
+
+ + {#if user.skin_url}{/if} +
+
+ +
+
+ Cape +
+ + {#each capes as c (c.id)} + + {/each} +
+ {#if !capes.length}Upload capes on the Capes page.{:else}{cape ? cape.name : 'No cape'} · admins can give any cape, including "given only" ones.{/if} +
+ +
+ In game {#if activity?.online_on.length} Online on {activity.online_on.map((s) => s.name).join(', ')}{/if} +
+ {duration(totals.playtime)}played + {totals.joins.toLocaleString()}joins + {totals.mined.toLocaleString()}mined + {totals.kills.toLocaleString()}kills + {totals.deaths.toLocaleString()}deaths +
+
+ + {#if activity?.servers.length} + + + + {#each activity.servers as s (s.server_id)} + + {/each} + +
ServerPlaytimeLast seen
{s.server_name}{duration(s.playtime_secs)}{timeAgo(s.last_seen)}
+ {:else if activity} +

Hasn't played on a connected server yet.

+ {/if} + + {#if activity?.events.length} +
+ Recent +
+ {#each activity.events.slice(0, 8) as e (e.id)} +
{e.kind}{e.detail ?? ''}{timeAgo(e.created_at)}
+ {/each} +
+
+ {/if} +
+
+ + diff --git a/panel/web/src/components/ServerForm.svelte b/panel/web/src/components/ServerForm.svelte new file mode 100644 index 0000000..a782646 --- /dev/null +++ b/panel/web/src/components/ServerForm.svelte @@ -0,0 +1,56 @@ + + + +
+ Who can join +
+ {#each accessOptions as o} + + {/each} +
+
+{#if draft.access === 'groups'} +
+ {#each groups as g} + {@const on = draft.allowed_groups.includes(g.name)} + + {:else} + Create groups on the Players page first. + {/each} +
+{/if} + + + diff --git a/panel/web/src/components/Sidebar.svelte b/panel/web/src/components/Sidebar.svelte index 3b3a7ad..f12fdcc 100644 --- a/panel/web/src/components/Sidebar.svelte +++ b/panel/web/src/components/Sidebar.svelte @@ -1,5 +1,5 @@ + + + + diff --git a/panel/web/src/components/TokenReveal.svelte b/panel/web/src/components/TokenReveal.svelte new file mode 100644 index 0000000..e089fd9 --- /dev/null +++ b/panel/web/src/components/TokenReveal.svelte @@ -0,0 +1,55 @@ + + +
Copy the token now — it won't be shown again.
+
{token}
+ +
    +
  1. + Install the SCOPENET integration + The plugin for Paper, Purpur or Spigot, or the mod for Fabric and Forge servers. + Download from Releases +
  2. +
  3. + Configure it +
    + + +
    + {tab === 'paper' ? 'plugins/SCOPENET/config.yml' : 'config/scopenet.properties'} +
    {snippet}
    +
  4. +
  5. + Use the panel's sign-in + Add this flag before -jar in the start command and keep online-mode=true. Download authlib-injector.jar next to the server jar. +
    -javaagent:authlib-injector.jar={panelUrl}/api/yggdrasil
    +
  6. +
+ + diff --git a/panel/web/src/lib/api.ts b/panel/web/src/lib/api.ts index d85b3db..5e91e2e 100644 --- a/panel/web/src/lib/api.ts +++ b/panel/web/src/lib/api.ts @@ -71,3 +71,15 @@ export const LOADERS = [ ] as const; export const loaderLabel = (id: string) => LOADERS.find((l) => l.id === id)?.label ?? id; + +export function duration(secs: number): string { + if (!secs) return '0m'; + const h = Math.floor(secs / 3600); + const m = Math.floor((secs % 3600) / 60); + if (h >= 100) return `${h.toLocaleString()}h`; + return h ? `${h}h ${m}m` : `${m}m`; +} + +export async function copy(text: string) { + await navigator.clipboard.writeText(text); +} diff --git a/panel/web/src/lib/types.ts b/panel/web/src/lib/types.ts index 9c4bf6d..704728b 100644 --- a/panel/web/src/lib/types.ts +++ b/panel/web/src/lib/types.ts @@ -61,6 +61,12 @@ export interface User { last_login: string | null; uuid: string; groups: string[]; + skin_url: string | null; + skin_model: 'classic' | 'slim'; + cape_id: number | null; + status_reason: string | null; + playtime_secs: number; + last_seen_ingame: string | null; } export interface Group { id: number; name: string; color: string; members: number } @@ -69,12 +75,66 @@ export interface Settings { auth: { panel_accounts: boolean; registration: 'closed' | 'open' | 'approval'; - microsoft: boolean; - microsoft_client_id: string | null; offline_local: boolean; }; + public_url: string | null; curseforge_api_key?: string | null; launcher_download_url: string | null; curseforge_key_set: boolean; curseforge_key_from_env: boolean; } + +export interface Cape { id: number; name: string; url: string; visibility: 'public' | 'groups' | 'private'; allowed_groups: string[]; wearers: number; created_at: string } + +export interface AuthServerInfo { public_url: string; yggdrasil_url: string; public_key: string } + +export interface GameServer { + id: number; + name: string; + token_hint: string; + access: 'all' | 'members' | 'groups'; + allowed_groups: string[]; + require_launcher: boolean; + software: string | null; + mc_version: string | null; + plugin_version: string | null; + online_mode: boolean | null; + max_players: number; + online_count: number; + tps: number | null; + last_seen: string | null; + created_at: string; + online: boolean; + players: number; +} + +export interface PlayerStats { + server_id: number; + uuid: string; + name: string; + playtime_secs: number; + joins: number; + deaths: number; + player_kills: number; + mob_kills: number; + blocks_broken: number; + blocks_placed: number; + messages: number; + first_seen: string; + last_seen: string; + server_name?: string; +} + +export interface ServerEvent { id: number; server_id: number; uuid: string | null; name: string | null; kind: string; detail: string | null; created_at: string } + +export interface ServerDetail { + server: GameServer; + online_players: { uuid: string; name: string; joined_at: string }[]; + leaderboard: PlayerStats[]; + events: ServerEvent[]; + totals: { players: number; playtime_secs: number }; +} + +export interface UserActivity { servers: PlayerStats[]; events: ServerEvent[]; online_on: { id: number; name: string }[] } + +export type ServerDraft = { name: string; access: 'all' | 'members' | 'groups'; allowed_groups: string[]; require_launcher: boolean }; diff --git a/panel/web/src/pages/Capes.svelte b/panel/web/src/pages/Capes.svelte new file mode 100644 index 0000000..6230c4c --- /dev/null +++ b/panel/web/src/pages/Capes.svelte @@ -0,0 +1,198 @@ + + +
+
+
+

Capes

+

Upload capes and decide who can wear them. They show up in game on every server that uses the panel's sign-in.

+
+ +
+ + {#if capes && !capes.length} +
+ +

No capes yet

+

Upload a 64×32 cape texture (or an HD multiple like 128×64) to get started.

+ +
+ {:else if capes} +
+ {#each capes as c (c.id)} + {@const vis = visibilities.find((v) => v.id === c.visibility)!} +
+
+
+ {c.name} +
+ {c.visibility === 'groups' ? c.allowed_groups.join(', ') || 'No groups' : vis.label} + {c.wearers} wearing +
+
+
+ + +
+
+ {/each} +
+ {/if} +
+ + + {#if draft} +
+ +
+ +
+ Who can wear it +
+ {#each visibilities as v} + + {/each} +
+
+ {#if draft.visibility === 'groups'} +
+ {#each groups as g} + {@const on = draft.allowed_groups.includes(g.name)} + + {:else} + Create groups on the Players page first. + {/each} +
+ {/if} +
+
+ {/if} + {#snippet footer()} + + + {/snippet} +
+ + +

{confirm?.wearers ? `${confirm.wearers} player${confirm.wearers === 1 ? '' : 's'} will lose it.` : 'Nobody is wearing it.'} This can't be undone.

+ {#snippet footer()} + + + {/snippet} +
+ + diff --git a/panel/web/src/pages/Dashboard.svelte b/panel/web/src/pages/Dashboard.svelte index 701fe70..677fb4b 100644 --- a/panel/web/src/pages/Dashboard.svelte +++ b/panel/web/src/pages/Dashboard.svelte @@ -1,5 +1,5 @@ @@ -41,6 +42,7 @@
{#each [ + { label: 'Online now', value: stats?.live?.online_now, icon: Radio, tone: 'live', href: '#/servers' }, { label: 'Active players (7d)', value: stats?.players_7d, icon: Activity, tone: 'accent' }, { label: 'Accounts', value: stats?.users, icon: Users, tone: 'cyan' }, { label: 'Awaiting approval', value: stats?.pending, icon: UserCheck, tone: 'warn', href: '#/users' }, @@ -80,6 +82,21 @@
+ {#if stats?.live?.servers?.length} +
+

Servers

View all
+ +
+ {/if} +

Popular instances

{#if stats?.top_instances?.length} @@ -118,7 +135,7 @@ diff --git a/panel/web/src/pages/Servers.svelte b/panel/web/src/pages/Servers.svelte new file mode 100644 index 0000000..0f48358 --- /dev/null +++ b/panel/web/src/pages/Servers.svelte @@ -0,0 +1,144 @@ + + +
+
+
+

Servers

+

Game servers running the SCOPENET plugin or mod report players, stats and events here — and ask the panel who may join.

+
+ +
+ + {#if servers && !servers.length} +
+ +

Connect your first server

+

Add a server to get a token, drop the SCOPENET plugin or mod on it, and you'll see live players, playtime leaderboards and events.

+
+ Live players & stats + Bans & access groups + "Launcher only" joins +
+ +
+ {:else if servers} +

{online} player{online === 1 ? '' : 's'} online across {servers.filter((s) => s.online).length} of {servers.length} server{servers.length === 1 ? '' : 's'}

+ + {/if} +
+ + + + {#snippet footer()} + + + {/snippet} + + + + {#if created}{/if} + {#snippet footer()} + + {/snippet} + + + diff --git a/panel/web/src/pages/Settings.svelte b/panel/web/src/pages/Settings.svelte index 797e239..f57a0a8 100644 --- a/panel/web/src/pages/Settings.svelte +++ b/panel/web/src/pages/Settings.svelte @@ -1,24 +1,34 @@