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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ARcGWxLx21FwXJ3yfGriS
This commit is contained in:
Claude committed 2026-09-28 09:12:59 +00:00
1 parent a50963f9e0
commit c7efd759a8
19 files changed
+1284 -38

No files matched your search

+32 -4
View File
@@ -35,10 +35,15 @@ pub async fn stats(_: AdminUser, State(state): State<AppState>) -> AppResult<Jso
.await?;
let recent: Vec<(Option<String>, Option<String>, 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<AppState>, Path
Ok(Json(view(&state, user).await?))
}
#[derive(Deserialize)]
pub struct AdminModelInput {
model: String,
}
pub async fn admin_set_model(
_: AdminUser,
State(state): State<AppState>,
Path(id): Path<i64>,
Json(input): Json<AdminModelInput>,
) -> AppResult<Json<AdminUserView>> {
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<i64>,