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:
19 files changed
+1284
-38
No files matched your search
@@ -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>,
|
||||
|
||||
@@ -45,6 +45,7 @@ pub fn api(state: &AppState) -> Router<AppState> {
|
||||
.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))
|
||||
|
||||
@@ -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 @@
|
||||
<InstanceEditor id={route.params[0]} />
|
||||
{:else if route.name === 'instances'}
|
||||
<Instances />
|
||||
{:else if route.name === 'servers' && route.params[0]}
|
||||
<ServerDetail id={route.params[0]} />
|
||||
{:else if route.name === 'servers'}
|
||||
<Servers />
|
||||
{:else if route.name === 'capes'}
|
||||
<Capes />
|
||||
{:else if route.name === 'users'}
|
||||
<Users />
|
||||
{:else if route.name === 'branding'}
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
<script lang="ts">
|
||||
let { name, size = 32 }: { name: string; size?: number } = $props();
|
||||
// Player head rendered by the panel from the stored skin; falls back to
|
||||
// a coloured initial for players without one.
|
||||
let { name, uuid = null, size = 32, v = 0 }: { name: string; uuid?: string | null; size?: number; v?: number } = $props();
|
||||
let failed = $state(false);
|
||||
const hue = $derived([...name].reduce((a, c) => a + c.charCodeAt(0), 0) % 360);
|
||||
const src = $derived(`/api/v1/avatar/${encodeURIComponent(uuid ?? name)}?size=${Math.max(16, Math.min(256, size * 2))}${v ? `&v=${v}` : ''}`);
|
||||
$effect(() => {
|
||||
void src;
|
||||
failed = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if failed}
|
||||
<span class="fallback" style:width="{size}px" style:height="{size}px" style:background="hsl({hue} 60% 45%)">{name.slice(0, 1).toUpperCase()}</span>
|
||||
<span class="fallback" style:width="{size}px" style:height="{size}px" style:font-size="{Math.max(10, size * 0.4)}px" style:background="hsl({hue} 45% 40%)">{name.slice(0, 1).toUpperCase()}</span>
|
||||
{:else}
|
||||
<img src="https://mc-heads.net/avatar/{encodeURIComponent(name)}/{size * 2}" alt="" width={size} height={size} onerror={() => (failed = true)} />
|
||||
<img {src} alt="" width={size} height={size} onerror={() => (failed = true)} />
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
img, .fallback { border-radius: 8px; image-rendering: pixelated; flex-shrink: 0; }
|
||||
.fallback { display: inline-grid; place-items: center; font-weight: 700; font-size: 0.85rem; color: white; }
|
||||
img, .fallback { border-radius: 25%; image-rendering: pixelated; flex-shrink: 0; }
|
||||
.fallback { display: inline-grid; place-items: center; font-weight: 650; color: white; }
|
||||
</style>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script lang="ts">
|
||||
// Outer face of a cape texture (64×32 and HD multiples, or legacy 22×17).
|
||||
let { src, scale = 5 }: { src: string | null; scale?: number } = $props();
|
||||
let canvas = $state<HTMLCanvasElement>();
|
||||
|
||||
$effect(() => {
|
||||
const el = canvas;
|
||||
const url = src;
|
||||
if (!el) return;
|
||||
const ctx = el.getContext('2d')!;
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
ctx.clearRect(0, 0, el.width, el.height);
|
||||
if (!url) return;
|
||||
let cancelled = false;
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
if (cancelled) return;
|
||||
const u = img.width === 22 ? 1 : img.width / 64;
|
||||
ctx.drawImage(img, u, u, 10 * u, 16 * u, 0, 0, el.width, el.height);
|
||||
};
|
||||
img.src = url;
|
||||
return () => (cancelled = true);
|
||||
});
|
||||
</script>
|
||||
|
||||
<canvas bind:this={canvas} width={10 * scale} height={16 * scale} aria-hidden="true"></canvas>
|
||||
|
||||
<style>
|
||||
canvas { image-rendering: pixelated; display: block; border-radius: 3px; background: rgba(255, 255, 255, 0.04); }
|
||||
</style>
|
||||
@@ -0,0 +1,153 @@
|
||||
<script lang="ts">
|
||||
import { Upload, RotateCcw, Clock, Skull, Pickaxe, Sword, LogIn, Circle } from '@lucide/svelte';
|
||||
import SkinView from './SkinView.svelte';
|
||||
import CapePreview from './CapePreview.svelte';
|
||||
import { api, del, duration, get, put, timeAgo } from '../lib/api';
|
||||
import { toast, toastError } from '../lib/toast.svelte';
|
||||
import type { Cape, User, UserActivity } from '../lib/types';
|
||||
|
||||
let { user, capes, onchange }: { user: User; capes: Cape[]; onchange: (u: User) => void } = $props();
|
||||
|
||||
let activity = $state<UserActivity | null>(null);
|
||||
let busy = $state(false);
|
||||
let version = $state(0);
|
||||
|
||||
$effect(() => {
|
||||
activity = null;
|
||||
get<UserActivity>(`/api/admin/users/${user.id}/activity`).then((a) => (activity = a)).catch(toastError);
|
||||
});
|
||||
|
||||
const cape = $derived(capes.find((c) => c.id === user.cape_id) ?? null);
|
||||
const skin = $derived(user.skin_url ? `${user.skin_url}?v=${version}` : null);
|
||||
const totals = $derived.by(() => {
|
||||
const t = { playtime: 0, deaths: 0, mined: 0, kills: 0, joins: 0 };
|
||||
for (const s of activity?.servers ?? []) {
|
||||
t.playtime += s.playtime_secs;
|
||||
t.deaths += s.deaths;
|
||||
t.mined += s.blocks_broken;
|
||||
t.kills += s.player_kills + s.mob_kills;
|
||||
t.joins += s.joins;
|
||||
}
|
||||
return t;
|
||||
});
|
||||
|
||||
async function run(p: Promise<User>, done: string) {
|
||||
busy = true;
|
||||
try {
|
||||
const u = await p;
|
||||
version++;
|
||||
onchange(u);
|
||||
toast(done);
|
||||
} catch (e) {
|
||||
toastError(e);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
function upload(e: Event & { currentTarget: HTMLInputElement }) {
|
||||
const f = e.currentTarget.files?.[0];
|
||||
e.currentTarget.value = '';
|
||||
if (!f) return;
|
||||
const form = new FormData();
|
||||
form.append('model', user.skin_model);
|
||||
form.append('file', f);
|
||||
run(api<User>(`/api/admin/users/${user.id}/skin`, { method: 'POST', form }), 'Skin updated');
|
||||
}
|
||||
const setModel = (model: string) => model !== user.skin_model && run(put<User>(`/api/admin/users/${user.id}/skin/model`, { model }), 'Arm style updated');
|
||||
const resetSkin = () => run(del<User>(`/api/admin/users/${user.id}/skin`), 'Skin reset');
|
||||
const setCape = (id: number | null) => run(put<User>(`/api/admin/users/${user.id}/cape`, { cape_id: id }), id ? 'Cape given' : 'Cape removed');
|
||||
</script>
|
||||
|
||||
<div class="panel">
|
||||
<div class="look" class:busy>
|
||||
<div class="figures">
|
||||
<SkinView {skin} cape={cape?.url ?? null} slim={user.skin_model === 'slim'} side="front" scale={6} />
|
||||
<SkinView {skin} cape={cape?.url ?? null} slim={user.skin_model === 'slim'} side="back" scale={6} />
|
||||
</div>
|
||||
<div class="segmented">
|
||||
<button class:active={user.skin_model === 'classic'} disabled={busy} onclick={() => setModel('classic')}>Classic</button>
|
||||
<button class:active={user.skin_model === 'slim'} disabled={busy} onclick={() => setModel('slim')}>Slim</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label class="btn sm up"><Upload size={14} /> Upload skin<input type="file" accept="image/png" onchange={upload} disabled={busy} /></label>
|
||||
{#if user.skin_url}<button class="sm ghost" disabled={busy} onclick={resetSkin}><RotateCcw size={14} /> Reset</button>{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info">
|
||||
<div class="field">
|
||||
<span class="lbl">Cape</span>
|
||||
<div class="capes">
|
||||
<button class="cape" class:on={!user.cape_id} disabled={busy} onclick={() => user.cape_id && setCape(null)} title="No cape"><span class="none"></span></button>
|
||||
{#each capes as c (c.id)}
|
||||
<button class="cape" class:on={user.cape_id === c.id} disabled={busy} onclick={() => user.cape_id !== c.id && setCape(c.id)} title={c.name}><CapePreview src={c.url} scale={3} /></button>
|
||||
{/each}
|
||||
</div>
|
||||
{#if !capes.length}<span class="muted tiny">Upload capes on the <a href="#/capes">Capes</a> page.</span>{:else}<span class="muted tiny">{cape ? cape.name : 'No cape'} · admins can give any cape, including "given only" ones.</span>{/if}
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<span class="lbl row between">In game {#if activity?.online_on.length}<span class="badge good"><Circle size={8} fill="currentColor" /> Online on {activity.online_on.map((s) => s.name).join(', ')}</span>{/if}</span>
|
||||
<div class="stats">
|
||||
<span><Clock size={14} /><strong>{duration(totals.playtime)}</strong><small>played</small></span>
|
||||
<span><LogIn size={14} /><strong>{totals.joins.toLocaleString()}</strong><small>joins</small></span>
|
||||
<span><Pickaxe size={14} /><strong>{totals.mined.toLocaleString()}</strong><small>mined</small></span>
|
||||
<span><Sword size={14} /><strong>{totals.kills.toLocaleString()}</strong><small>kills</small></span>
|
||||
<span><Skull size={14} /><strong>{totals.deaths.toLocaleString()}</strong><small>deaths</small></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if activity?.servers.length}
|
||||
<table class="table mini">
|
||||
<thead><tr><th>Server</th><th class="num">Playtime</th><th class="num">Last seen</th></tr></thead>
|
||||
<tbody>
|
||||
{#each activity.servers as s (s.server_id)}
|
||||
<tr><td><a href="#/servers/{s.server_id}">{s.server_name}</a></td><td class="num">{duration(s.playtime_secs)}</td><td class="num muted">{timeAgo(s.last_seen)}</td></tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{:else if activity}
|
||||
<p class="muted small">Hasn't played on a connected server yet.</p>
|
||||
{/if}
|
||||
|
||||
{#if activity?.events.length}
|
||||
<div class="field">
|
||||
<span class="lbl">Recent</span>
|
||||
<div class="events">
|
||||
{#each activity.events.slice(0, 8) as e (e.id)}
|
||||
<div class="ev"><span class="kind">{e.kind}</span><span class="grow">{e.detail ?? ''}</span><span class="muted tiny">{timeAgo(e.created_at)}</span></div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.panel { display: grid; grid-template-columns: auto 1fr; gap: 24px; }
|
||||
.look { display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 18px; background: var(--bg-2); border: 1px solid var(--line); border-radius: var(--radius); transition: opacity 0.15s; align-self: start; }
|
||||
.look.busy { opacity: 0.6; }
|
||||
.figures { display: flex; gap: 14px; }
|
||||
.up { position: relative; overflow: hidden; padding: 6px 11px; font-size: 0.85rem; }
|
||||
.up input { position: absolute; inset: 0; opacity: 0; cursor: pointer; }
|
||||
.info { display: flex; flex-direction: column; gap: 18px; min-width: 0; }
|
||||
.field { display: flex; flex-direction: column; gap: 8px; }
|
||||
.lbl { font-size: 0.85rem; color: var(--text-2); font-weight: 500; }
|
||||
.between { justify-content: space-between; }
|
||||
.capes { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.cape { padding: 5px; background: var(--bg-2); border-radius: 6px; }
|
||||
.cape.on { border-color: var(--accent); background: var(--accent-soft); }
|
||||
.none { display: block; width: 30px; height: 48px; border-radius: 3px; background: repeating-linear-gradient(135deg, transparent 0 5px, rgba(255, 255, 255, 0.06) 5px 10px); }
|
||||
.stats { display: grid; grid-template-columns: repeat(5, 1fr); gap: 6px; }
|
||||
.stats span { display: flex; flex-direction: column; gap: 2px; padding: 10px; border-radius: 8px; background: var(--bg-2); border: 1px solid var(--line); }
|
||||
.stats :global(svg) { color: var(--muted); margin-bottom: 4px; }
|
||||
.stats strong { font-variant-numeric: tabular-nums; font-size: 0.95rem; }
|
||||
.stats small { color: var(--muted); font-size: 0.72rem; }
|
||||
.mini td, .mini th { padding: 8px 10px; }
|
||||
.num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.events { display: flex; flex-direction: column; gap: 6px; }
|
||||
.ev { display: flex; gap: 10px; align-items: baseline; font-size: 0.83rem; }
|
||||
.kind { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted); width: 84px; flex-shrink: 0; }
|
||||
.grow { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-2); }
|
||||
@media (max-width: 700px) { .panel { grid-template-columns: 1fr; } .stats { grid-template-columns: repeat(3, 1fr); } }
|
||||
</style>
|
||||
@@ -0,0 +1,56 @@
|
||||
<script lang="ts">
|
||||
import { Globe, UserRound, Tag } from '@lucide/svelte';
|
||||
import Toggle from './Toggle.svelte';
|
||||
import type { Group, ServerDraft } from '../lib/types';
|
||||
|
||||
let { draft = $bindable(), groups }: { draft: ServerDraft; groups: Group[] } = $props();
|
||||
|
||||
const accessOptions = [
|
||||
{ id: 'all', label: 'Anyone', help: 'Anyone who can connect. Panel accounts are still checked for bans.', icon: Globe },
|
||||
{ id: 'members', label: 'Panel accounts', help: 'Only players with an active account on this panel.', icon: UserRound },
|
||||
{ id: 'groups', label: 'Groups', help: 'Only accounts in the groups you pick (admins always get in).', icon: Tag },
|
||||
] as const;
|
||||
</script>
|
||||
|
||||
<label class="field">Name<input bind:value={draft.name} maxlength="48" placeholder="Survival SMP" /></label>
|
||||
<div class="field">
|
||||
<span class="lbl">Who can join</span>
|
||||
<div class="opts">
|
||||
{#each accessOptions as o}
|
||||
<button type="button" class="opt" class:on={draft.access === o.id} onclick={() => (draft.access = o.id)}>
|
||||
<o.icon size={16} /><span><strong>{o.label}</strong><span class="tiny muted">{o.help}</span></span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{#if draft.access === 'groups'}
|
||||
<div class="row wrap">
|
||||
{#each groups as g}
|
||||
{@const on = draft.allowed_groups.includes(g.name)}
|
||||
<button type="button" class="gpick" class:on style:--c={g.color} onclick={() => (draft.allowed_groups = on ? draft.allowed_groups.filter((x) => x !== g.name) : [...draft.allowed_groups, g.name])}>
|
||||
<span class="dot" style:background={g.color}></span>{g.name}
|
||||
</button>
|
||||
{:else}
|
||||
<span class="muted small">Create groups on the Players page first.</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<Toggle
|
||||
bind:checked={draft.require_launcher}
|
||||
label="Must join through the launcher"
|
||||
help="Accounts are only let in if they launched the game from the SCOPENET launcher on the same network in the last 12 hours. Useful for offline-mode servers."
|
||||
/>
|
||||
|
||||
<style>
|
||||
.field { display: flex; flex-direction: column; gap: 7px; }
|
||||
.lbl { font-size: 0.85rem; color: var(--text-2); font-weight: 500; }
|
||||
.opts { display: flex; flex-direction: column; gap: 6px; }
|
||||
.opt { justify-content: flex-start; gap: 12px; padding: 10px 12px; text-align: left; white-space: normal; background: var(--bg-2); }
|
||||
.opt > span { display: flex; flex-direction: column; gap: 2px; }
|
||||
.opt :global(svg) { color: var(--muted); flex-shrink: 0; }
|
||||
.opt.on { border-color: color-mix(in srgb, var(--accent) 60%, transparent); background: var(--accent-soft); }
|
||||
.opt.on :global(svg) { color: var(--accent-2); }
|
||||
.gpick { padding: 6px 12px; border-radius: 99px; font-size: 0.85rem; background: var(--bg-2); }
|
||||
.gpick.on { border-color: var(--c); background: color-mix(in srgb, var(--c) 18%, transparent); }
|
||||
.dot { width: 10px; height: 10px; border-radius: 50%; }
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { LayoutDashboard, Boxes, Users, Palette, Settings, LogOut, ExternalLink } from '@lucide/svelte';
|
||||
import { LayoutDashboard, Boxes, Users, Palette, Settings, LogOut, ExternalLink, Server, Flag } from '@lucide/svelte';
|
||||
import { route } from '../lib/router.svelte';
|
||||
import { logout, session } from '../lib/session.svelte';
|
||||
import Avatar from './Avatar.svelte';
|
||||
@@ -10,6 +10,8 @@
|
||||
{ id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
||||
{ id: 'instances', label: 'Instances', icon: Boxes },
|
||||
{ id: 'users', label: 'Players', icon: Users },
|
||||
{ id: 'servers', label: 'Servers', icon: Server },
|
||||
{ id: 'capes', label: 'Capes', icon: Flag },
|
||||
{ id: 'branding', label: 'Launcher design', icon: Palette },
|
||||
{ id: 'settings', label: 'Settings', icon: Settings },
|
||||
];
|
||||
@@ -41,7 +43,7 @@
|
||||
|
||||
{#if session.user}
|
||||
<div class="me">
|
||||
<Avatar name={session.user.username} size={34} />
|
||||
<Avatar name={session.user.username} uuid={session.user.uuid} size={34} />
|
||||
<div class="who">
|
||||
<strong>{session.user.username}</strong>
|
||||
<span>Administrator</span>
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
<script lang="ts">
|
||||
// Flat front/back render of a Minecraft skin (+ cape) on a canvas.
|
||||
let { skin, cape = null, slim = false, side = 'front', scale = 8 }: {
|
||||
skin: string | null; cape?: string | null; slim?: boolean; side?: 'front' | 'back'; scale?: number;
|
||||
} = $props();
|
||||
|
||||
let canvas = $state<HTMLCanvasElement>();
|
||||
|
||||
function load(src: string): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = reject;
|
||||
img.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
type Part = [sx: number, sy: number, w: number, h: number, dx: number, dy: number, mirror?: boolean];
|
||||
|
||||
function parts(legacy: boolean): { base: Part[]; overlay: Part[] } {
|
||||
const aw = slim ? 3 : 4;
|
||||
const off = slim ? 1 : 0; // slim arms sit one pixel closer to the body
|
||||
if (side === 'front') {
|
||||
const base: Part[] = [
|
||||
[8, 8, 8, 8, 4, 0],
|
||||
[20, 20, 8, 12, 4, 8],
|
||||
[44, 20, aw, 12, off, 8],
|
||||
legacy ? [44, 20, aw, 12, 12, 8, true] : [36, 52, aw, 12, 12, 8],
|
||||
[4, 20, 4, 12, 4, 20],
|
||||
legacy ? [4, 20, 4, 12, 8, 20, true] : [20, 52, 4, 12, 8, 20],
|
||||
];
|
||||
const overlay: Part[] = [[40, 8, 8, 8, 4, 0]];
|
||||
if (!legacy) overlay.push([20, 36, 8, 12, 4, 8], [44, 36, aw, 12, off, 8], [52, 52, aw, 12, 12, 8], [4, 36, 4, 12, 4, 20], [4, 52, 4, 12, 8, 20]);
|
||||
return { base, overlay };
|
||||
}
|
||||
const rb = slim ? 51 : 52; // back face of the right arm
|
||||
const lb = slim ? 43 : 44;
|
||||
const base: Part[] = [
|
||||
[24, 8, 8, 8, 4, 0],
|
||||
[32, 20, 8, 12, 4, 8],
|
||||
[rb, 20, aw, 12, 12, 8],
|
||||
legacy ? [rb, 20, aw, 12, off, 8, true] : [lb, 52, aw, 12, off, 8],
|
||||
[12, 20, 4, 12, 8, 20],
|
||||
legacy ? [12, 20, 4, 12, 4, 20, true] : [28, 52, 4, 12, 4, 20],
|
||||
];
|
||||
const overlay: Part[] = [[56, 8, 8, 8, 4, 0]];
|
||||
if (!legacy) overlay.push([32, 36, 8, 12, 4, 8], [rb, 36, aw, 12, 12, 8], [lb + 16, 52, aw, 12, off, 8], [12, 36, 4, 12, 8, 20], [12, 52, 4, 12, 4, 20]);
|
||||
return { base, overlay };
|
||||
}
|
||||
|
||||
function draw(ctx: CanvasRenderingContext2D, img: HTMLImageElement, [sx, sy, w, h, dx, dy, mirror]: Part) {
|
||||
if (mirror) {
|
||||
ctx.save();
|
||||
ctx.translate((dx + w) * scale, dy * scale);
|
||||
ctx.scale(-1, 1);
|
||||
ctx.drawImage(img, sx, sy, w, h, 0, 0, w * scale, h * scale);
|
||||
ctx.restore();
|
||||
} else {
|
||||
ctx.drawImage(img, sx, sy, w, h, dx * scale, dy * scale, w * scale, h * scale);
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const el = canvas;
|
||||
const src = skin, capeSrc = cape, s = side, sl = slim;
|
||||
void s, sl;
|
||||
if (!el) return;
|
||||
const ctx = el.getContext('2d')!;
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
ctx.clearRect(0, 0, el.width, el.height);
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
if (src) {
|
||||
const img = await load(src).catch(() => null);
|
||||
if (!img || cancelled) return;
|
||||
const { base, overlay } = parts(img.height === 32);
|
||||
base.forEach((p) => draw(ctx, img, p));
|
||||
overlay.forEach((p) => draw(ctx, img, p));
|
||||
} else {
|
||||
// Silhouette placeholder.
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.07)';
|
||||
for (const [x, y, w, h] of [[4, 0, 8, 8], [4, 8, 8, 12], [0, 8, 4, 12], [12, 8, 4, 12], [4, 20, 4, 12], [8, 20, 4, 12]]) {
|
||||
ctx.fillRect(x * scale, y * scale, w * scale, h * scale);
|
||||
}
|
||||
}
|
||||
if (capeSrc && s === 'back') {
|
||||
const c = await load(capeSrc).catch(() => null);
|
||||
if (!c || cancelled) return;
|
||||
const u = c.width / 64; // HD capes
|
||||
ctx.drawImage(c, 1 * u, 1 * u, 10 * u, 16 * u, 3 * scale, 8 * scale, 10 * scale, 16 * scale);
|
||||
}
|
||||
})();
|
||||
return () => (cancelled = true);
|
||||
});
|
||||
</script>
|
||||
|
||||
<canvas bind:this={canvas} width={16 * scale} height={32 * scale} aria-label="Skin {side}"></canvas>
|
||||
|
||||
<style>
|
||||
canvas { image-rendering: pixelated; display: block; }
|
||||
</style>
|
||||
@@ -0,0 +1,55 @@
|
||||
<script lang="ts">
|
||||
import { Copy, TriangleAlert, Download } from '@lucide/svelte';
|
||||
import { copy } from '../lib/api';
|
||||
import { toast } from '../lib/toast.svelte';
|
||||
|
||||
let { token, panelUrl }: { token: string; panelUrl: string } = $props();
|
||||
let tab = $state<'paper' | 'mod'>('paper');
|
||||
|
||||
const snippet = $derived(
|
||||
tab === 'paper' ? `panel-url: "${panelUrl}"\ntoken: "${token}"` : `panel-url=${panelUrl}\ntoken=${token}`,
|
||||
);
|
||||
|
||||
async function copyText(t: string) {
|
||||
await copy(t);
|
||||
toast('Copied');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="warn"><TriangleAlert size={16} /> Copy the token now — it won't be shown again.</div>
|
||||
<div class="copyline"><code>{token}</code><button class="ghost icon sm" aria-label="Copy token" onclick={() => copyText(token)}><Copy size={14} /></button></div>
|
||||
|
||||
<ol class="steps">
|
||||
<li>
|
||||
<strong>Install the SCOPENET integration</strong>
|
||||
<span class="muted small">The plugin for Paper, Purpur or Spigot, or the mod for Fabric and Forge servers.</span>
|
||||
<a class="dl" href="https://github.com/scopeddlol/SCOPENET-MC/releases/latest" target="_blank" rel="noreferrer"><Download size={14} /> Download from Releases</a>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Configure it</strong>
|
||||
<div class="segmented">
|
||||
<button class:active={tab === 'paper'} onclick={() => (tab = 'paper')}>Paper</button>
|
||||
<button class:active={tab === 'mod'} onclick={() => (tab = 'mod')}>Fabric / Forge</button>
|
||||
</div>
|
||||
<span class="muted tiny"><code>{tab === 'paper' ? 'plugins/SCOPENET/config.yml' : 'config/scopenet.properties'}</code></span>
|
||||
<div class="code"><pre>{snippet}</pre><button class="ghost icon sm" aria-label="Copy config" onclick={() => copyText(snippet)}><Copy size={14} /></button></div>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Use the panel's sign-in</strong>
|
||||
<span class="muted small">Add this flag before <code>-jar</code> in the start command and keep <code>online-mode=true</code>. Download <a href="/api/v1/launcher/authlib-injector.jar">authlib-injector.jar</a> next to the server jar.</span>
|
||||
<div class="copyline"><code>-javaagent:authlib-injector.jar={panelUrl}/api/yggdrasil</code><button class="ghost icon sm" aria-label="Copy start flag" onclick={() => copyText(`-javaagent:authlib-injector.jar=${panelUrl}/api/yggdrasil`)}><Copy size={14} /></button></div>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<style>
|
||||
.warn { display: flex; align-items: center; gap: 8px; color: #fcd34d; font-size: 0.85rem; }
|
||||
.copyline, .code { display: flex; align-items: flex-start; gap: 6px; background: var(--bg-2); border: 1px solid var(--line); border-radius: var(--radius-sm); padding: 4px 4px 4px 12px; }
|
||||
.copyline { align-items: center; }
|
||||
.copyline code { flex: 1; overflow-x: auto; white-space: nowrap; padding: 8px 0; font-size: 0.82rem; }
|
||||
.code pre { flex: 1; margin: 0; padding: 8px 0; font-family: var(--mono); font-size: 0.8rem; overflow-x: auto; }
|
||||
.steps { margin: 4px 0 0; padding-left: 20px; display: flex; flex-direction: column; gap: 16px; }
|
||||
.steps li { display: flex; flex-direction: column; gap: 8px; }
|
||||
.steps li::marker { color: var(--muted); }
|
||||
.segmented { align-self: flex-start; }
|
||||
.dl { display: inline-flex; align-items: center; gap: 6px; font-size: 0.85rem; align-self: flex-start; }
|
||||
</style>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,198 @@
|
||||
<script lang="ts">
|
||||
import { Upload, Pencil, Trash2, Globe, Tag, Lock, Flag, Users as UsersIcon, ImagePlus } from '@lucide/svelte';
|
||||
import CapePreview from '../components/CapePreview.svelte';
|
||||
import Modal from '../components/Modal.svelte';
|
||||
import { api, del, get, put } from '../lib/api';
|
||||
import { toast, toastError } from '../lib/toast.svelte';
|
||||
import type { Cape, Group } from '../lib/types';
|
||||
|
||||
type Draft = { id?: number; name: string; visibility: Cape['visibility']; allowed_groups: string[]; file: File | null; preview: string | null };
|
||||
|
||||
let capes = $state<Cape[] | null>(null);
|
||||
let groups = $state<Group[]>([]);
|
||||
let draft = $state<Draft | null>(null);
|
||||
let draftOpen = $state(false);
|
||||
let saving = $state(false);
|
||||
let confirm = $state<Cape | null>(null);
|
||||
let confirmOpen = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
get<Cape[]>('/api/admin/capes').then((c) => (capes = c)).catch(toastError);
|
||||
get<Group[]>('/api/admin/groups').then((g) => (groups = g)).catch(() => {});
|
||||
});
|
||||
|
||||
const visibilities = [
|
||||
{ id: 'public', label: 'Everyone', help: 'Any player can pick it in the launcher.', icon: Globe },
|
||||
{ id: 'groups', label: 'Groups', help: 'Players in the chosen groups can pick it.', icon: Tag },
|
||||
{ id: 'private', label: 'Given only', help: 'Only admins can give it out, from the Players page.', icon: Lock },
|
||||
] as const;
|
||||
|
||||
function openNew() {
|
||||
draft = { name: '', visibility: 'public', allowed_groups: [], file: null, preview: null };
|
||||
draftOpen = true;
|
||||
}
|
||||
function openEdit(c: Cape) {
|
||||
draft = { id: c.id, name: c.name, visibility: c.visibility, allowed_groups: [...c.allowed_groups], file: null, preview: c.url };
|
||||
draftOpen = true;
|
||||
}
|
||||
function pick(e: Event & { currentTarget: HTMLInputElement }) {
|
||||
const f = e.currentTarget.files?.[0];
|
||||
if (!f || !draft) return;
|
||||
draft.file = f;
|
||||
draft.preview = URL.createObjectURL(f);
|
||||
if (!draft.name) draft.name = f.name.replace(/\.png$/i, '').replace(/[-_]+/g, ' ').slice(0, 40);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!draft) return;
|
||||
saving = true;
|
||||
try {
|
||||
if (draft.id) {
|
||||
capes = await put<Cape[]>(`/api/admin/capes/${draft.id}`, { name: draft.name, visibility: draft.visibility, allowed_groups: draft.allowed_groups });
|
||||
toast('Cape updated');
|
||||
} else {
|
||||
const form = new FormData();
|
||||
form.append('name', draft.name);
|
||||
form.append('visibility', draft.visibility);
|
||||
form.append('allowed_groups', JSON.stringify(draft.allowed_groups));
|
||||
form.append('file', draft.file!);
|
||||
capes = await api<Cape[]>('/api/admin/capes', { method: 'POST', form });
|
||||
toast(`${draft.name} added`);
|
||||
}
|
||||
draftOpen = false;
|
||||
} catch (e) {
|
||||
toastError(e);
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
if (!confirm) return;
|
||||
try {
|
||||
capes = await del<Cape[]>(`/api/admin/capes/${confirm.id}`);
|
||||
toast('Cape deleted');
|
||||
confirmOpen = false;
|
||||
} catch (e) {
|
||||
toastError(e);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1>Capes</h1>
|
||||
<p>Upload capes and decide who can wear them. They show up in game on every server that uses the panel's sign-in.</p>
|
||||
</div>
|
||||
<button class="primary" onclick={openNew}><Upload size={16} /> Upload cape</button>
|
||||
</div>
|
||||
|
||||
{#if capes && !capes.length}
|
||||
<section class="card empty">
|
||||
<Flag size={32} />
|
||||
<h3>No capes yet</h3>
|
||||
<p>Upload a 64×32 cape texture (or an HD multiple like 128×64) to get started.</p>
|
||||
<button class="primary add" onclick={openNew}><Upload size={16} /> Upload cape</button>
|
||||
</section>
|
||||
{:else if capes}
|
||||
<div class="grid capes">
|
||||
{#each capes as c (c.id)}
|
||||
{@const vis = visibilities.find((v) => v.id === c.visibility)!}
|
||||
<article class="card cape">
|
||||
<div class="stage"><CapePreview src={c.url} scale={7} /></div>
|
||||
<div class="meta">
|
||||
<strong>{c.name}</strong>
|
||||
<div class="row wrap tags">
|
||||
<span class="badge" class:accent={c.visibility === 'public'}><vis.icon size={11} /> {c.visibility === 'groups' ? c.allowed_groups.join(', ') || 'No groups' : vis.label}</span>
|
||||
<span class="muted tiny row gap"><UsersIcon size={12} /> {c.wearers} wearing</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="ghost icon" aria-label="Edit {c.name}" onclick={() => openEdit(c)}><Pencil size={15} /></button>
|
||||
<button class="ghost icon" aria-label="Delete {c.name}" onclick={() => { confirm = c; confirmOpen = true; }}><Trash2 size={15} /></button>
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Modal bind:open={draftOpen} title={draft?.id ? `Edit ${draft.name}` : 'Upload cape'} width={560}>
|
||||
{#if draft}
|
||||
<div class="editor">
|
||||
<label class="drop" class:has={draft.preview}>
|
||||
{#if draft.preview}<CapePreview src={draft.preview} scale={8} />{:else}<ImagePlus size={24} /><span class="tiny">Choose PNG</span>{/if}
|
||||
{#if !draft.id}<input type="file" accept="image/png" onchange={pick} />{/if}
|
||||
</label>
|
||||
<div class="col grow">
|
||||
<label class="field">Name<input bind:value={draft.name} maxlength="40" placeholder="Founder" /></label>
|
||||
<div class="field">
|
||||
<span class="lbl">Who can wear it</span>
|
||||
<div class="vis">
|
||||
{#each visibilities as v}
|
||||
<button type="button" class="opt" class:on={draft.visibility === v.id} onclick={() => draft && (draft.visibility = v.id)}>
|
||||
<v.icon size={16} /><span><strong>{v.label}</strong><span class="tiny muted">{v.help}</span></span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{#if draft.visibility === 'groups'}
|
||||
<div class="row wrap">
|
||||
{#each groups as g}
|
||||
{@const on = draft.allowed_groups.includes(g.name)}
|
||||
<button type="button" class="gpick" class:on style:--c={g.color} onclick={() => draft && (draft.allowed_groups = on ? draft.allowed_groups.filter((x) => x !== g.name) : [...draft.allowed_groups, g.name])}>
|
||||
<span class="dot" style:background={g.color}></span>{g.name}
|
||||
</button>
|
||||
{:else}
|
||||
<span class="muted small">Create groups on the Players page first.</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#snippet footer()}
|
||||
<button class="ghost" onclick={() => (draftOpen = false)}>Cancel</button>
|
||||
<button class="primary" disabled={saving || !draft?.name.trim() || (!draft?.id && !draft?.file)} onclick={save}>{draft?.id ? 'Save' : 'Upload'}</button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<Modal bind:open={confirmOpen} title="Delete {confirm?.name}?">
|
||||
<p class="muted">{confirm?.wearers ? `${confirm.wearers} player${confirm.wearers === 1 ? '' : 's'} will lose it.` : 'Nobody is wearing it.'} This can't be undone.</p>
|
||||
{#snippet footer()}
|
||||
<button class="ghost" onclick={() => (confirmOpen = false)}>Cancel</button>
|
||||
<button class="danger" onclick={remove}><Trash2 size={16} /> Delete</button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.capes { grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); }
|
||||
.cape { padding: 0; overflow: hidden; display: flex; flex-direction: column; position: relative; }
|
||||
.stage { display: grid; place-items: center; padding: 22px; background: var(--bg-2); border-bottom: 1px solid var(--line); }
|
||||
.meta { padding: 14px 16px 16px; display: flex; flex-direction: column; gap: 8px; }
|
||||
.tags { gap: 10px; }
|
||||
.gap { gap: 5px; }
|
||||
.actions { position: absolute; top: 8px; right: 8px; display: flex; gap: 2px; opacity: 0; transition: opacity 0.15s; }
|
||||
.cape:hover .actions, .cape:focus-within .actions { opacity: 1; }
|
||||
.actions button { background: color-mix(in srgb, var(--surface) 85%, transparent); }
|
||||
.empty :global(svg) { color: var(--muted); }
|
||||
.empty .add { margin-top: 18px; }
|
||||
.editor { display: flex; gap: 20px; align-items: flex-start; }
|
||||
.grow { flex: 1; min-width: 0; }
|
||||
.drop { position: relative; width: 120px; height: 168px; flex-shrink: 0; display: flex; flex-direction: column; gap: 8px; align-items: center; justify-content: center; border: 1px dashed var(--line-strong); border-radius: var(--radius); color: var(--muted); background: var(--bg-2); cursor: pointer; }
|
||||
.drop.has { border-style: solid; }
|
||||
.drop input { position: absolute; inset: 0; opacity: 0; cursor: pointer; }
|
||||
.field { display: flex; flex-direction: column; gap: 7px; }
|
||||
.lbl { font-size: 0.85rem; color: var(--text-2); font-weight: 500; }
|
||||
.vis { display: flex; flex-direction: column; gap: 6px; }
|
||||
.opt { justify-content: flex-start; gap: 12px; padding: 10px 12px; text-align: left; white-space: normal; background: var(--bg-2); }
|
||||
.opt > span { display: flex; flex-direction: column; gap: 2px; }
|
||||
.opt :global(svg) { color: var(--muted); flex-shrink: 0; }
|
||||
.opt.on { border-color: color-mix(in srgb, var(--accent) 60%, transparent); background: var(--accent-soft); }
|
||||
.opt.on :global(svg) { color: var(--accent-2); }
|
||||
.gpick { padding: 6px 12px; border-radius: 99px; font-size: 0.85rem; background: var(--bg-2); }
|
||||
.gpick.on { border-color: var(--c); background: color-mix(in srgb, var(--c) 18%, transparent); }
|
||||
.dot { width: 10px; height: 10px; border-radius: 50%; }
|
||||
@media (max-width: 560px) { .editor { flex-direction: column; align-items: center; } }
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { Users, UserCheck, Boxes, Activity, Download, ArrowRight, Palette, Rocket, CircleCheck, Circle } from '@lucide/svelte';
|
||||
import { Users, UserCheck, Boxes, Activity, Download, ArrowRight, Palette, Rocket, CircleCheck, Circle, Radio, Server } from '@lucide/svelte';
|
||||
import BarChart from '../components/BarChart.svelte';
|
||||
import Avatar from '../components/Avatar.svelte';
|
||||
import { get, timeAgo } from '../lib/api';
|
||||
@@ -25,6 +25,7 @@
|
||||
{ done: instances.length > 0, label: 'Create your first instance', href: '#/instances', icon: Boxes },
|
||||
{ done: !!stats?.launcher_download_url, label: 'Share the launcher download link', href: '#/settings', icon: Download },
|
||||
{ done: total > 0, label: 'Launch the game once', href: '#/instances', icon: Rocket },
|
||||
{ done: !!stats?.live?.servers?.length, label: 'Connect a game server', href: '#/servers', icon: Server },
|
||||
]);
|
||||
</script>
|
||||
|
||||
@@ -41,6 +42,7 @@
|
||||
|
||||
<div class="grid stats">
|
||||
{#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 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if stats?.live?.servers?.length}
|
||||
<section class="card servers">
|
||||
<div class="section-title"><h2>Servers</h2><a class="hint" href="#/servers">View all</a></div>
|
||||
<div class="list">
|
||||
{#each stats.live.servers as s}
|
||||
<a class="srv" href="#/servers/{s.id}">
|
||||
<span class="dot" class:on={s.online}></span>
|
||||
<span class="name">{s.name}</span>
|
||||
<span class="muted small num">{s.online ? `${s.players} / ${s.max_players || '–'}` : 'offline'}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<section class="card">
|
||||
<div class="section-title"><h2>Popular instances</h2></div>
|
||||
{#if stats?.top_instances?.length}
|
||||
@@ -118,7 +135,7 @@
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.stats { grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); margin-bottom: 16px; }
|
||||
.stats { grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); margin-bottom: 16px; }
|
||||
.stat { display: flex; flex-direction: column; gap: 4px; color: var(--text); position: relative; overflow: hidden; }
|
||||
.stat .icon { width: 36px; height: 36px; border-radius: 10px; display: grid; place-items: center; margin-bottom: 10px; }
|
||||
.stat .value { font-size: 1.75rem; font-weight: 600; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; }
|
||||
@@ -126,6 +143,12 @@
|
||||
.accent .icon { background: var(--accent-soft); color: #b9b7fb; }
|
||||
.cyan .icon { background: rgba(34, 211, 238, 0.12); color: #67e8f9; }
|
||||
.warn .icon { background: rgba(251, 191, 36, 0.12); color: #fcd34d; }
|
||||
.live .icon { background: rgba(63, 185, 124, 0.12); color: #6ee7b7; }
|
||||
.srv { display: flex; align-items: center; gap: 10px; color: var(--text); font-size: 0.9rem; padding: 2px 0; }
|
||||
.srv .name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.srv .num { font-variant-numeric: tabular-nums; }
|
||||
.srv .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--surface-3); }
|
||||
.srv .dot.on { background: var(--good); }
|
||||
.green .icon { background: rgba(52, 211, 153, 0.12); color: #6ee7b7; }
|
||||
.main { grid-template-columns: 1.6fr 1fr; }
|
||||
@media (max-width: 1100px) { .main { grid-template-columns: 1fr; } }
|
||||
|
||||
@@ -442,7 +442,7 @@
|
||||
<h3>Who can see this instance?</h3>
|
||||
<div class="vis">
|
||||
{#each [
|
||||
{ id: 'public', icon: Globe, title: 'Everyone', text: 'Including offline and Microsoft accounts' },
|
||||
{ id: 'public', icon: Globe, title: 'Everyone', text: 'Including local offline accounts' },
|
||||
{ id: 'members', icon: UsersIcon, title: 'Signed-in players', text: 'Anyone with a panel account' },
|
||||
{ id: 'groups', icon: Lock, title: 'Specific groups', text: 'Only members of the groups below' },
|
||||
] as v}
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
ArrowLeft, Settings2, KeyRound, Trash2, Users, Gauge, Clock, UserRound, LogIn, LogOut, Skull, Award, Sword, MessageSquare,
|
||||
CircleDot, Ban, Server,
|
||||
} from '@lucide/svelte';
|
||||
import Avatar from '../components/Avatar.svelte';
|
||||
import Modal from '../components/Modal.svelte';
|
||||
import ServerForm from '../components/ServerForm.svelte';
|
||||
import TokenReveal from '../components/TokenReveal.svelte';
|
||||
import { del, duration, get, post, put, timeAgo } from '../lib/api';
|
||||
import { go } from '../lib/router.svelte';
|
||||
import { toast, toastError } from '../lib/toast.svelte';
|
||||
import type { AuthServerInfo, Group, ServerDetail, ServerDraft, ServerEvent } from '../lib/types';
|
||||
|
||||
let { id }: { id: string } = $props();
|
||||
|
||||
let d = $state<ServerDetail | null>(null);
|
||||
let sort = $state('playtime_secs');
|
||||
let groups = $state<Group[]>([]);
|
||||
let info = $state<AuthServerInfo | null>(null);
|
||||
let draft = $state<ServerDraft>({ name: '', access: 'all', allowed_groups: [], require_launcher: false });
|
||||
let editOpen = $state(false);
|
||||
let token = $state<string | null>(null);
|
||||
let tokenOpen = $state(false);
|
||||
let regenOpen = $state(false);
|
||||
let deleteOpen = $state(false);
|
||||
|
||||
const load = () =>
|
||||
get<ServerDetail>(`/api/admin/servers/${id}?sort=${sort}`)
|
||||
.then((x) => (d = x))
|
||||
.catch((e) => {
|
||||
toastError(e);
|
||||
if (e.status === 404) go('servers');
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
void sort;
|
||||
load();
|
||||
});
|
||||
$effect(() => {
|
||||
get<Group[]>('/api/admin/groups').then((g) => (groups = g)).catch(() => {});
|
||||
get<AuthServerInfo>('/api/admin/auth-server').then((i) => (info = i)).catch(() => {});
|
||||
const t = setInterval(load, 15000);
|
||||
return () => clearInterval(t);
|
||||
});
|
||||
|
||||
const s = $derived(d?.server);
|
||||
const columns = [
|
||||
{ id: 'playtime_secs', label: 'Playtime' },
|
||||
{ id: 'joins', label: 'Joins' },
|
||||
{ id: 'deaths', label: 'Deaths' },
|
||||
{ id: 'player_kills', label: 'PvP kills' },
|
||||
{ id: 'mob_kills', label: 'Mob kills' },
|
||||
{ id: 'blocks_broken', label: 'Mined' },
|
||||
{ id: 'blocks_placed', label: 'Placed' },
|
||||
{ id: 'messages', label: 'Chat' },
|
||||
] as const;
|
||||
|
||||
const eventIcon: Record<string, any> = { join: LogIn, quit: LogOut, death: Skull, advancement: Award, kill: Sword, chat: MessageSquare, kick: Ban, denied: Ban, start: Server, stop: Server };
|
||||
function eventText(e: ServerEvent): string {
|
||||
switch (e.kind) {
|
||||
case 'join': return 'joined the game';
|
||||
case 'quit': return 'left the game';
|
||||
case 'denied': return e.detail ? `was refused: ${e.detail}` : 'was refused';
|
||||
case 'kick': return e.detail ? `was kicked: ${e.detail}` : 'was kicked';
|
||||
case 'advancement': return e.detail ? `made the advancement [${e.detail}]` : 'made an advancement';
|
||||
case 'start': return 'Server started';
|
||||
case 'stop': return 'Server stopped';
|
||||
default: return e.detail ?? e.kind;
|
||||
}
|
||||
}
|
||||
// Death messages already contain the player's name.
|
||||
const showName = (e: ServerEvent) => e.name && !(e.kind === 'death' && e.detail?.startsWith(e.name)) && e.kind !== 'start' && e.kind !== 'stop';
|
||||
|
||||
const n = (v: number) => v.toLocaleString();
|
||||
const cell = (row: any, c: string) => (c === 'playtime_secs' ? duration(row[c]) : n(row[c]));
|
||||
const tpsTone = (t: number | null | undefined) => (t == null ? '' : t >= 18 ? 'good' : t >= 14 ? 'warn' : 'bad');
|
||||
|
||||
function openEdit() {
|
||||
if (!s) return;
|
||||
draft = { name: s.name, access: s.access, allowed_groups: [...s.allowed_groups], require_launcher: s.require_launcher };
|
||||
editOpen = true;
|
||||
}
|
||||
async function saveEdit() {
|
||||
try {
|
||||
await put(`/api/admin/servers/${id}`, draft);
|
||||
editOpen = false;
|
||||
toast('Server updated');
|
||||
load();
|
||||
} catch (e) {
|
||||
toastError(e);
|
||||
}
|
||||
}
|
||||
async function regenerate() {
|
||||
try {
|
||||
token = (await post<{ token: string }>(`/api/admin/servers/${id}/token`)).token;
|
||||
regenOpen = false;
|
||||
tokenOpen = true;
|
||||
load();
|
||||
} catch (e) {
|
||||
toastError(e);
|
||||
}
|
||||
}
|
||||
async function remove() {
|
||||
try {
|
||||
await del(`/api/admin/servers/${id}`);
|
||||
toast('Server removed');
|
||||
go('servers');
|
||||
} catch (e) {
|
||||
toastError(e);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<a class="back" href="#/servers"><ArrowLeft size={15} /> Servers</a>
|
||||
{#if s && d}
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1 class="row title"><span class="status" class:on={s.online}></span>{s.name}</h1>
|
||||
<p>
|
||||
{[
|
||||
s.software ? `${s.software}${s.mc_version ? ` ${s.mc_version}` : ''}` : 'Not connected yet',
|
||||
s.plugin_version ? `integration ${s.plugin_version}` : null,
|
||||
s.online ? 'online' : s.last_seen ? `last seen ${timeAgo(s.last_seen)}` : null,
|
||||
].filter(Boolean).join(' · ')}
|
||||
</p>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button onclick={openEdit}><Settings2 size={16} /> Settings</button>
|
||||
<button onclick={() => (regenOpen = true)}><KeyRound size={16} /> New token</button>
|
||||
<button class="ghost icon" aria-label="Remove server" onclick={() => (deleteOpen = true)}><Trash2 size={16} /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if !s.last_seen}
|
||||
<section class="card connect">
|
||||
<h3>Waiting for the server to connect</h3>
|
||||
<p class="muted small">Install the SCOPENET plugin or mod and put the token (ending in <code>…{s.token_hint}</code>) in its config. Lost it? Generate a new one.</p>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<div class="grid tiles">
|
||||
<div class="card tile"><span class="lbl"><Users size={14} /> Online now</span><span class="val">{s.players}<small> / {s.max_players || '–'}</small></span></div>
|
||||
<div class="card tile {tpsTone(s.online ? s.tps : null)}"><span class="lbl"><Gauge size={14} /> TPS</span><span class="val">{s.online && s.tps != null ? s.tps.toFixed(1) : '–'}</span></div>
|
||||
<div class="card tile"><span class="lbl"><UserRound size={14} /> Players seen</span><span class="val">{n(d.totals.players)}</span></div>
|
||||
<div class="card tile"><span class="lbl"><Clock size={14} /> Total playtime</span><span class="val">{duration(d.totals.playtime_secs)}</span></div>
|
||||
</div>
|
||||
|
||||
<div class="grid layout">
|
||||
<section class="card board">
|
||||
<div class="section-title">
|
||||
<h2>Leaderboard</h2>
|
||||
<select class="sort" bind:value={sort} aria-label="Sort by">
|
||||
{#each columns as c}<option value={c.id}>{c.label}</option>{/each}
|
||||
<option value="last_seen">Recently seen</option>
|
||||
</select>
|
||||
</div>
|
||||
{#if d.leaderboard.length}
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>#</th><th>Player</th>{#each columns as c}<th class="num" class:sorted={sort === c.id}>{c.label}</th>{/each}<th>Last seen</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each d.leaderboard as row, i (row.uuid)}
|
||||
<tr>
|
||||
<td class="rank" class:top={i < 3 && sort !== 'last_seen'}>{i + 1}</td>
|
||||
<td><span class="row who"><Avatar name={row.name} uuid={row.uuid} size={24} />{row.name}</span></td>
|
||||
{#each columns as c}<td class="num" class:sorted={sort === c.id}>{cell(row, c.id)}</td>{/each}
|
||||
<td class="muted small nowrap">{d.online_players.some((p) => p.uuid === row.uuid) ? 'Online' : timeAgo(row.last_seen)}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="muted small">Stats appear once players join.</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<div class="col side">
|
||||
<section class="card">
|
||||
<div class="section-title"><h2>Online</h2><span class="hint">{d.online_players.length}</span></div>
|
||||
{#if d.online_players.length}
|
||||
<div class="online">
|
||||
{#each d.online_players as p (p.uuid)}
|
||||
<div class="row op"><Avatar name={p.name} uuid={p.uuid} size={26} /><span class="grow">{p.name}</span><span class="muted tiny">{timeAgo(p.joined_at).replace(' ago', '')}</span></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="muted small">Nobody's online.</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="section-title"><h2>Activity</h2></div>
|
||||
{#if d.events.length}
|
||||
<div class="feed">
|
||||
{#each d.events as e (e.id)}
|
||||
{@const Icon = eventIcon[e.kind] ?? CircleDot}
|
||||
<div class="ev {e.kind}">
|
||||
<span class="ic"><Icon size={13} /></span>
|
||||
<span class="txt">{#if showName(e)}<strong>{e.name}</strong>{' '}{/if}{eventText(e)}</span>
|
||||
<span class="muted tiny nowrap">{timeAgo(e.created_at)}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="muted small">Joins, deaths and advancements show up here.</p>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="skeleton"></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Modal bind:open={editOpen} title="Server settings" width={540}>
|
||||
<ServerForm bind:draft {groups} />
|
||||
{#snippet footer()}
|
||||
<button class="ghost" onclick={() => (editOpen = false)}>Cancel</button>
|
||||
<button class="primary" disabled={!draft.name.trim() || (draft.access === 'groups' && !draft.allowed_groups.length)} onclick={saveEdit}>Save</button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<Modal bind:open={regenOpen} title="Generate a new token?">
|
||||
<p class="muted">The current token stops working immediately — the server disconnects from the panel until you update its config.</p>
|
||||
{#snippet footer()}
|
||||
<button class="ghost" onclick={() => (regenOpen = false)}>Cancel</button>
|
||||
<button class="primary" onclick={regenerate}><KeyRound size={16} /> Generate</button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<Modal bind:open={tokenOpen} title="New token for {s?.name}" width={600}>
|
||||
{#if token}<TokenReveal {token} panelUrl={info?.public_url ?? location.origin} />{/if}
|
||||
{#snippet footer()}
|
||||
<button class="primary" onclick={() => (tokenOpen = false)}>Done</button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<Modal bind:open={deleteOpen} title="Remove {s?.name}?">
|
||||
<p class="muted">Its token stops working and all stats and events collected for it are deleted.</p>
|
||||
{#snippet footer()}
|
||||
<button class="ghost" onclick={() => (deleteOpen = false)}>Cancel</button>
|
||||
<button class="danger" onclick={remove}><Trash2 size={16} /> Remove</button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.back { display: inline-flex; align-items: center; gap: 6px; color: var(--muted); font-size: 0.85rem; margin-bottom: 14px; }
|
||||
.back:hover { color: var(--text); }
|
||||
.title { gap: 12px; }
|
||||
.status { width: 11px; height: 11px; border-radius: 50%; background: color-mix(in srgb, var(--muted) 45%, transparent); flex-shrink: 0; }
|
||||
.status.on { background: var(--good); box-shadow: 0 0 0 4px color-mix(in srgb, var(--good) 20%, transparent); }
|
||||
.connect { margin-bottom: 16px; border-color: color-mix(in srgb, var(--accent) 35%, transparent); background: color-mix(in srgb, var(--accent) 6%, var(--surface)); display: flex; flex-direction: column; gap: 6px; }
|
||||
.tiles { grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); margin-bottom: 16px; }
|
||||
.tile { display: flex; flex-direction: column; gap: 8px; padding: 16px 18px; }
|
||||
.tile .lbl { display: flex; align-items: center; gap: 6px; color: var(--muted); font-size: 0.8rem; }
|
||||
.tile .val { font-size: 1.6rem; font-weight: 600; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; }
|
||||
.tile .val small { font-size: 0.9rem; color: var(--muted); font-weight: 500; }
|
||||
.tile.good .val { color: #6ee7b7; }
|
||||
.tile.warn .val { color: #fcd34d; }
|
||||
.tile.bad .val { color: #fda4af; }
|
||||
.layout { grid-template-columns: minmax(0, 1fr) 320px; align-items: start; }
|
||||
@media (max-width: 1150px) { .layout { grid-template-columns: 1fr; } }
|
||||
.board { padding-bottom: 8px; }
|
||||
.sort { width: auto; margin-left: auto; padding: 7px 34px 7px 12px; font-size: 0.85rem; }
|
||||
.table-wrap { overflow-x: auto; margin: 0 -20px; }
|
||||
.table th, .table td { padding: 10px 9px; }
|
||||
.table th:first-child, .table td:first-child { padding-left: 20px; }
|
||||
.table th:last-child, .table td:last-child { padding-right: 20px; }
|
||||
.table { font-size: 0.85rem; }
|
||||
.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
th.num { text-align: right; }
|
||||
.sorted { color: var(--text); }
|
||||
td.num:not(.sorted) { color: var(--text-2); }
|
||||
.rank { color: var(--muted); font-variant-numeric: tabular-nums; width: 36px; }
|
||||
.rank.top { color: #fcd34d; font-weight: 600; }
|
||||
.who { gap: 10px; white-space: nowrap; }
|
||||
.nowrap { white-space: nowrap; }
|
||||
.side { gap: 16px; }
|
||||
.online { display: flex; flex-direction: column; gap: 8px; max-height: 320px; overflow-y: auto; }
|
||||
.op { gap: 10px; font-size: 0.9rem; }
|
||||
.grow { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
||||
.feed { display: flex; flex-direction: column; gap: 10px; max-height: 520px; overflow-y: auto; }
|
||||
.ev { display: grid; grid-template-columns: 24px 1fr auto; gap: 10px; align-items: start; font-size: 0.85rem; line-height: 1.4; }
|
||||
.ic { width: 24px; height: 24px; border-radius: 6px; display: grid; place-items: center; background: var(--bg-2); color: var(--muted); }
|
||||
.ev.join .ic { color: #6ee7b7; }
|
||||
.ev.death .ic, .ev.denied .ic, .ev.kick .ic { color: #fda4af; }
|
||||
.ev.advancement .ic { color: #fcd34d; }
|
||||
.txt { color: var(--text-2); padding-top: 3px; }
|
||||
.txt strong { color: var(--text); font-weight: 560; }
|
||||
.ev .tiny { padding-top: 5px; }
|
||||
.skeleton { height: 420px; border-radius: var(--radius); background: var(--surface); }
|
||||
</style>
|
||||
@@ -0,0 +1,144 @@
|
||||
<script lang="ts">
|
||||
import { Plus, Server, Users, Gauge, ShieldCheck, Rocket, ChevronRight } from '@lucide/svelte';
|
||||
import Modal from '../components/Modal.svelte';
|
||||
import ServerForm from '../components/ServerForm.svelte';
|
||||
import TokenReveal from '../components/TokenReveal.svelte';
|
||||
import { get, post, timeAgo } from '../lib/api';
|
||||
import { go } from '../lib/router.svelte';
|
||||
import { toastError } from '../lib/toast.svelte';
|
||||
import type { AuthServerInfo, GameServer, Group, ServerDraft } from '../lib/types';
|
||||
|
||||
let servers = $state<GameServer[] | null>(null);
|
||||
let groups = $state<Group[]>([]);
|
||||
let info = $state<AuthServerInfo | null>(null);
|
||||
let draft = $state<ServerDraft>({ name: '', access: 'all', allowed_groups: [], require_launcher: false });
|
||||
let createOpen = $state(false);
|
||||
let creating = $state(false);
|
||||
let created = $state<{ id: number; token: string } | null>(null);
|
||||
let tokenOpen = $state(false);
|
||||
|
||||
const load = () => get<GameServer[]>('/api/admin/servers').then((s) => (servers = s)).catch(toastError);
|
||||
$effect(() => {
|
||||
load();
|
||||
get<Group[]>('/api/admin/groups').then((g) => (groups = g)).catch(() => {});
|
||||
get<AuthServerInfo>('/api/admin/auth-server').then((i) => (info = i)).catch(() => {});
|
||||
const t = setInterval(load, 15000);
|
||||
return () => clearInterval(t);
|
||||
});
|
||||
|
||||
const online = $derived(servers?.reduce((a, s) => a + s.players, 0) ?? 0);
|
||||
|
||||
function openCreate() {
|
||||
draft = { name: '', access: 'all', allowed_groups: [], require_launcher: false };
|
||||
createOpen = true;
|
||||
}
|
||||
|
||||
async function create() {
|
||||
creating = true;
|
||||
try {
|
||||
const r = await post<{ server: GameServer; token: string }>('/api/admin/servers', draft);
|
||||
created = { id: r.server.id, token: r.token };
|
||||
createOpen = false;
|
||||
tokenOpen = true;
|
||||
load();
|
||||
} catch (e) {
|
||||
toastError(e);
|
||||
} finally {
|
||||
creating = false;
|
||||
}
|
||||
}
|
||||
|
||||
const accessLabel = (s: GameServer) => (s.access === 'all' ? 'Anyone' : s.access === 'members' ? 'Panel accounts' : s.allowed_groups.join(', '));
|
||||
const tpsTone = (t: number | null) => (t == null ? '' : t >= 18 ? 'good' : t >= 14 ? 'warn' : 'bad');
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1>Servers</h1>
|
||||
<p>Game servers running the SCOPENET plugin or mod report players, stats and events here — and ask the panel who may join.</p>
|
||||
</div>
|
||||
<button class="primary" onclick={openCreate}><Plus size={16} /> Add server</button>
|
||||
</div>
|
||||
|
||||
{#if servers && !servers.length}
|
||||
<section class="card empty">
|
||||
<Server size={32} />
|
||||
<h3>Connect your first server</h3>
|
||||
<p>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.</p>
|
||||
<div class="features">
|
||||
<span><Users size={15} /> Live players & stats</span>
|
||||
<span><ShieldCheck size={15} /> Bans & access groups</span>
|
||||
<span><Rocket size={15} /> "Launcher only" joins</span>
|
||||
</div>
|
||||
<button class="primary add" onclick={openCreate}><Plus size={16} /> Add server</button>
|
||||
</section>
|
||||
{:else if servers}
|
||||
<p class="summary muted small"><span class="live"></span> {online} player{online === 1 ? '' : 's'} online across {servers.filter((s) => s.online).length} of {servers.length} server{servers.length === 1 ? '' : 's'}</p>
|
||||
<div class="list">
|
||||
{#each servers as s (s.id)}
|
||||
<a class="card hover srv" href="#/servers/{s.id}">
|
||||
<span class="status" class:on={s.online} title={s.online ? 'Online' : 'Offline'}></span>
|
||||
<div class="who">
|
||||
<strong>{s.name}</strong>
|
||||
<span class="muted tiny">
|
||||
{#if s.software}{s.software}{s.mc_version ? ` ${s.mc_version}` : ''}{:else}Waiting for the first connection…{/if}
|
||||
{#if s.last_seen && !s.online} · last seen {timeAgo(s.last_seen)}{/if}
|
||||
</span>
|
||||
</div>
|
||||
<div class="metric"><Users size={14} /><strong>{s.players}</strong><span class="muted">/ {s.max_players || '–'}</span></div>
|
||||
<div class="metric {tpsTone(s.online ? s.tps : null)}"><Gauge size={14} /><strong>{s.online && s.tps != null ? s.tps.toFixed(1) : '–'}</strong><span class="muted">TPS</span></div>
|
||||
<div class="tags">
|
||||
<span class="badge">{accessLabel(s)}</span>
|
||||
{#if s.require_launcher}<span class="badge accent">Launcher only</span>{/if}
|
||||
{#if s.online_mode === false}<span class="badge warn">Offline mode</span>{/if}
|
||||
</div>
|
||||
<ChevronRight size={16} class="chev" />
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Modal bind:open={createOpen} title="Add server" width={540}>
|
||||
<ServerForm bind:draft {groups} />
|
||||
{#snippet footer()}
|
||||
<button class="ghost" onclick={() => (createOpen = false)}>Cancel</button>
|
||||
<button class="primary" disabled={creating || !draft.name.trim() || (draft.access === 'groups' && !draft.allowed_groups.length)} onclick={create}>Create & get token</button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<Modal bind:open={tokenOpen} title="Connect {draft.name || 'your server'}" width={600}>
|
||||
{#if created}<TokenReveal token={created.token} panelUrl={info?.public_url ?? location.origin} />{/if}
|
||||
{#snippet footer()}
|
||||
<button class="primary" onclick={() => { tokenOpen = false; if (created) go(`servers/${created.id}`); }}>Done</button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.summary { display: flex; align-items: center; gap: 8px; margin: -12px 0 14px; }
|
||||
.live { width: 8px; height: 8px; border-radius: 50%; background: var(--good); }
|
||||
.list { display: flex; flex-direction: column; gap: 10px; }
|
||||
.srv { display: grid; grid-template-columns: auto minmax(0, 1.6fr) 110px 110px minmax(0, 1.2fr) auto; align-items: center; gap: 18px; padding: 16px 18px; color: var(--text); }
|
||||
.status { width: 10px; height: 10px; border-radius: 50%; background: color-mix(in srgb, var(--muted) 45%, transparent); }
|
||||
.status.on { background: var(--good); box-shadow: 0 0 0 3px color-mix(in srgb, var(--good) 22%, transparent); }
|
||||
.who { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
|
||||
.who strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.metric { display: flex; align-items: center; gap: 6px; font-variant-numeric: tabular-nums; font-size: 0.9rem; }
|
||||
.metric :global(svg) { color: var(--muted); }
|
||||
.metric.good strong { color: #6ee7b7; }
|
||||
.metric.warn strong { color: #fcd34d; }
|
||||
.metric.bad strong { color: #fda4af; }
|
||||
.tags { display: flex; gap: 6px; flex-wrap: wrap; justify-content: flex-end; }
|
||||
.srv :global(.chev) { color: var(--muted); }
|
||||
.empty :global(svg) { color: var(--muted); }
|
||||
.empty p { max-width: 520px; margin: 0 auto; line-height: 1.5; }
|
||||
.features { display: flex; gap: 20px; justify-content: center; margin-top: 18px; flex-wrap: wrap; font-size: 0.85rem; color: var(--text-2); }
|
||||
.features span { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.features :global(svg) { color: var(--accent-2); }
|
||||
.add { margin-top: 20px; }
|
||||
@media (max-width: 900px) {
|
||||
.srv { grid-template-columns: auto 1fr auto; }
|
||||
.srv .metric:nth-of-type(2), .srv .tags, .srv :global(.chev) { display: none; }
|
||||
}
|
||||
</style>
|
||||
@@ -1,24 +1,34 @@
|
||||
<script lang="ts">
|
||||
import { Save, LoaderCircle, KeyRound, UserRound, WifiOff, Download, ExternalLink, CircleCheck } from '@lucide/svelte';
|
||||
import { Save, LoaderCircle, KeyRound, UserRound, WifiOff, Download, CircleCheck, Copy } from '@lucide/svelte';
|
||||
import Toggle from '../components/Toggle.svelte';
|
||||
import { get, put } from '../lib/api';
|
||||
import { copy, get, put } from '../lib/api';
|
||||
import { toast, toastError } from '../lib/toast.svelte';
|
||||
import type { Settings } from '../lib/types';
|
||||
import type { AuthServerInfo, Settings } from '../lib/types';
|
||||
|
||||
let s = $state<Settings | null>(null);
|
||||
let cfKey = $state('');
|
||||
let saving = $state(false);
|
||||
|
||||
let info = $state<AuthServerInfo | null>(null);
|
||||
const loadInfo = () => get<AuthServerInfo>('/api/admin/auth-server').then((x) => (info = x)).catch(toastError);
|
||||
|
||||
$effect(() => {
|
||||
get<Settings>('/api/admin/settings').then((x) => (s = x)).catch(toastError);
|
||||
loadInfo();
|
||||
});
|
||||
|
||||
async function copyText(t: string) {
|
||||
await copy(t);
|
||||
toast('Copied');
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!s) return;
|
||||
saving = true;
|
||||
try {
|
||||
s = await put<Settings>('/api/admin/settings', { ...$state.snapshot(s), curseforge_api_key: cfKey });
|
||||
cfKey = '';
|
||||
loadInfo();
|
||||
toast('Settings saved');
|
||||
} catch (e) {
|
||||
toastError(e);
|
||||
@@ -42,7 +52,7 @@
|
||||
{#if s}
|
||||
<section class="card col">
|
||||
<div class="section-title"><UserRound size={18} /><h2>Panel accounts</h2></div>
|
||||
<Toggle bind:checked={s.auth.panel_accounts} label="Allow panel accounts" help="Players sign in with a username and password you manage here, then play in offline mode under that name." />
|
||||
<Toggle bind:checked={s.auth.panel_accounts} label="Allow panel accounts" help="Players sign in with a username and password you manage here. Their UUID, skin and cape come from the panel's auth server." />
|
||||
{#if s.auth.panel_accounts}
|
||||
<div class="field">
|
||||
<span class="lbl">Sign-ups from the launcher</span>
|
||||
@@ -64,17 +74,33 @@
|
||||
</section>
|
||||
|
||||
<section class="card col">
|
||||
<div class="section-title"><KeyRound size={18} /><h2>Microsoft accounts</h2></div>
|
||||
<Toggle bind:checked={s.auth.microsoft} label="Sign in with Microsoft" help="For players who own Minecraft — real skins and online-mode servers." />
|
||||
{#if s.auth.microsoft}
|
||||
<label class="field">Azure application (client) ID
|
||||
<input class="mono" bind:value={s.auth.microsoft_client_id} placeholder="00000000-0000-0000-0000-000000000000" />
|
||||
<div class="section-title"><KeyRound size={18} /><h2>Auth server</h2><span class="badge good">Yggdrasil</span></div>
|
||||
<p class="help">
|
||||
The panel is a complete authlib-injector auth server: accounts, UUIDs, skins and capes all live here.
|
||||
Point your game servers at it and they verify players against the panel, just like official servers do with Mojang.
|
||||
</p>
|
||||
<label class="field">Public address
|
||||
<input bind:value={s.public_url} placeholder={info?.public_url ?? 'https://panel.example.com'} />
|
||||
<span class="help">The address players and game servers reach the panel at. Skin URLs and the auth server use it. Leave empty to detect it from each request.</span>
|
||||
</label>
|
||||
{#if info}
|
||||
<div class="field">
|
||||
<span class="lbl">Auth server URL</span>
|
||||
<div class="copyline"><code>{info.yggdrasil_url}</code><button class="ghost icon sm" aria-label="Copy auth server URL" onclick={() => copyText(info!.yggdrasil_url)}><Copy size={14} /></button></div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="lbl">Game server start flag</span>
|
||||
<div class="copyline"><code>-javaagent:authlib-injector.jar={info.yggdrasil_url}</code><button class="ghost icon sm" aria-label="Copy start flag" onclick={() => copyText(`-javaagent:authlib-injector.jar=${info!.yggdrasil_url}`)}><Copy size={14} /></button></div>
|
||||
<span class="help">
|
||||
Register a public client app in Azure (Personal Microsoft accounts, device-code flow enabled), then
|
||||
<a href="https://aka.ms/mce-reviewappid" target="_blank" rel="noreferrer">request Minecraft API access <ExternalLink size={11} /></a>.
|
||||
See the docs for the step-by-step guide.
|
||||
Add this before <code>-jar</code> on each server, keep <code>online-mode=true</code>, and download
|
||||
<a href="/api/v1/launcher/authlib-injector.jar">authlib-injector.jar</a> next to the server jar.
|
||||
Then add the server under <a href="#/servers">Servers</a> for stats and access control.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<details>
|
||||
<summary>Signing public key</summary>
|
||||
<pre class="key">{info.public_key}</pre>
|
||||
</details>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
@@ -105,5 +131,9 @@
|
||||
.lbl { font-size: 0.85rem; color: var(--text-2); font-weight: 500; }
|
||||
.help { color: var(--muted); font-weight: 400; font-size: 0.8rem; line-height: 1.5; }
|
||||
.segmented { align-self: flex-start; }
|
||||
.copyline { display: flex; align-items: center; gap: 6px; background: var(--bg-2); border: 1px solid var(--line); border-radius: var(--radius-sm); padding: 4px 4px 4px 12px; }
|
||||
.copyline code { flex: 1; overflow-x: auto; white-space: nowrap; color: var(--text); font-weight: 400; padding: 6px 0; }
|
||||
details summary { cursor: pointer; font-size: 0.85rem; color: var(--text-2); font-weight: 500; }
|
||||
.key { margin: 10px 0 0; padding: 12px; background: var(--bg-2); border: 1px solid var(--line); border-radius: var(--radius-sm); font-family: var(--mono); font-size: 0.72rem; color: var(--muted); overflow-x: auto; }
|
||||
.set { display: flex; align-items: center; gap: 8px; color: var(--good); font-weight: 400; }
|
||||
</style>
|
||||
@@ -2,10 +2,11 @@
|
||||
import { UserPlus, Check, Pencil, Trash2, Shield, Plus, X, Search, Tag } from '@lucide/svelte';
|
||||
import Avatar from '../components/Avatar.svelte';
|
||||
import Modal from '../components/Modal.svelte';
|
||||
import { del, get, patch, post, timeAgo } from '../lib/api';
|
||||
import PlayerPanel from '../components/PlayerPanel.svelte';
|
||||
import { del, duration, get, patch, post, timeAgo } from '../lib/api';
|
||||
import { session } from '../lib/session.svelte';
|
||||
import { toast, toastError } from '../lib/toast.svelte';
|
||||
import type { Group, User } from '../lib/types';
|
||||
import type { Cape, Group, User } from '../lib/types';
|
||||
|
||||
let users = $state<User[]>([]);
|
||||
let groups = $state<Group[]>([]);
|
||||
@@ -15,11 +16,27 @@
|
||||
let confirm = $state<User | null>(null);
|
||||
let confirmOpen = $state(false);
|
||||
let newGroup = $state({ name: '', color: '#22d3ee' });
|
||||
let capes = $state<Cape[]>([]);
|
||||
let viewing = $state<User | null>(null);
|
||||
let viewOpen = $state(false);
|
||||
let avatarV = $state(0);
|
||||
|
||||
const refresh = () => {
|
||||
get<User[]>('/api/admin/users').then((u) => (users = u)).catch(toastError);
|
||||
get<Group[]>('/api/admin/groups').then((g) => (groups = g)).catch(toastError);
|
||||
get<Cape[]>('/api/admin/capes').then((c) => (capes = c)).catch(() => {});
|
||||
};
|
||||
|
||||
function openView(u: User) {
|
||||
viewing = u;
|
||||
viewOpen = true;
|
||||
}
|
||||
function updated(u: User) {
|
||||
viewing = u;
|
||||
users = users.map((x) => (x.id === u.id ? u : x));
|
||||
avatarV++;
|
||||
}
|
||||
const lastSeen = (u: User) => [u.last_login, u.last_seen_ingame].filter(Boolean).sort().at(-1) ?? null;
|
||||
$effect(refresh);
|
||||
|
||||
const pending = $derived(users.filter((u) => u.status === 'pending'));
|
||||
@@ -38,7 +55,7 @@
|
||||
async function saveUser() {
|
||||
if (!editing) return;
|
||||
try {
|
||||
const body = { username: editing.username, password: editing.password || undefined, role: editing.role, status: editing.status, groups: editing.groups, email: editing.email ?? '' };
|
||||
const body = { username: editing.username, password: editing.password || undefined, role: editing.role, status: editing.status, status_reason: editing.status === 'disabled' ? (editing.status_reason ?? '') : '', groups: editing.groups, email: editing.email ?? '' };
|
||||
if (editing.id) await patch(`/api/admin/users/${editing.id}`, body);
|
||||
else await post('/api/admin/users', body);
|
||||
toast(editing.id ? 'Player updated' : `Created ${editing.username}`);
|
||||
@@ -96,7 +113,7 @@
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1>Players</h1>
|
||||
<p>Accounts sign in from the launcher and play with their username in offline mode.</p>
|
||||
<p>Accounts, UUIDs, skins and capes all live here. Players sign in through the launcher, and your servers verify them with the panel.</p>
|
||||
</div>
|
||||
<button class="primary" onclick={openNew}><UserPlus size={18} /> Add player</button>
|
||||
</div>
|
||||
@@ -106,7 +123,7 @@
|
||||
<strong>{pending.length} player{pending.length > 1 ? 's' : ''} waiting for approval</strong>
|
||||
<div class="row wrap">
|
||||
{#each pending as u}
|
||||
<span class="pend"><Avatar name={u.username} size={22} /> {u.username}
|
||||
<span class="pend"><Avatar name={u.username} uuid={u.uuid} size={22} /> {u.username}
|
||||
<button class="sm primary" onclick={() => approve(u)}><Check size={14} /> Approve</button>
|
||||
</span>
|
||||
{/each}
|
||||
@@ -122,13 +139,13 @@
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<thead><tr><th>Player</th><th>Role</th><th>Groups</th><th>Last seen</th><th></th></tr></thead>
|
||||
<thead><tr><th>Player</th><th>Role</th><th>Groups</th><th>Playtime</th><th>Last seen</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{#each shown as u (u.id)}
|
||||
<tr>
|
||||
<tr class="clickable" onclick={(e) => !(e.target as HTMLElement).closest('button') && openView(u)}>
|
||||
<td>
|
||||
<div class="row who">
|
||||
<Avatar name={u.username} size={34} />
|
||||
<Avatar name={u.username} uuid={u.uuid} size={34} v={avatarV} />
|
||||
<div class="col tight">
|
||||
<strong>{u.username}{#if u.id === session.user?.id} <span class="muted tiny">(you)</span>{/if}</strong>
|
||||
<span class="mono tiny muted">{u.uuid}</span>
|
||||
@@ -140,7 +157,8 @@
|
||||
{#if u.status === 'pending'}<span class="badge warn">Pending</span>{:else if u.status === 'disabled'}<span class="badge bad">Disabled</span>{/if}
|
||||
</td>
|
||||
<td><div class="row wrap chips">{#each u.groups as g}<span class="gchip" style:--c={colorOf(g)}>{g}</span>{/each}</div></td>
|
||||
<td class="muted small">{timeAgo(u.last_login)}</td>
|
||||
<td class="small num">{u.playtime_secs ? duration(u.playtime_secs) : '–'}</td>
|
||||
<td class="muted small">{timeAgo(lastSeen(u))}</td>
|
||||
<td class="actions">
|
||||
<button class="ghost icon" aria-label="Edit {u.username}" onclick={() => openEdit(u)}><Pencil size={15} /></button>
|
||||
{#if u.id !== session.user?.id}
|
||||
@@ -194,6 +212,11 @@
|
||||
<select bind:value={editing.status}><option value="active">Active</option><option value="pending">Pending approval</option><option value="disabled">Disabled</option></select>
|
||||
</label>
|
||||
</div>
|
||||
{#if editing.status === 'disabled'}
|
||||
<label class="field">Reason <span class="help">Shown to the player in the launcher and when a server refuses them. Online players are kicked within 30 seconds.</span>
|
||||
<input bind:value={editing.status_reason} maxlength="200" placeholder="e.g. Griefing at spawn — appeal on Discord" />
|
||||
</label>
|
||||
{/if}
|
||||
{#if groups.length}
|
||||
<div class="field">
|
||||
<span class="lbl">Groups</span>
|
||||
@@ -215,6 +238,20 @@
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<Modal bind:open={viewOpen} title={viewing?.username ?? ''} width={780}>
|
||||
{#if viewing}
|
||||
<div class="row head">
|
||||
<span class="mono tiny muted">{viewing.uuid}</span>
|
||||
{#if viewing.status === 'disabled'}<span class="badge bad">Disabled{viewing.status_reason ? ` · ${viewing.status_reason}` : ''}</span>{/if}
|
||||
</div>
|
||||
<PlayerPanel user={viewing} {capes} onchange={updated} />
|
||||
{/if}
|
||||
{#snippet footer()}
|
||||
<button class="ghost" onclick={() => { if (viewing) { viewOpen = false; openEdit(viewing); } }}><Pencil size={15} /> Edit account</button>
|
||||
<button class="primary" onclick={() => (viewOpen = false)}>Done</button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<Modal bind:open={confirmOpen} title="Delete {confirm?.username}?">
|
||||
<p class="muted">They won't be able to sign in any more. This can't be undone.</p>
|
||||
{#snippet footer()}
|
||||
@@ -235,6 +272,9 @@
|
||||
.tight { gap: 2px; }
|
||||
.chips { gap: 4px; }
|
||||
.gchip { font-size: 0.75rem; padding: 2px 8px; border-radius: 99px; background: color-mix(in srgb, var(--c) 16%, transparent); color: color-mix(in srgb, var(--c) 70%, white); border: 1px solid color-mix(in srgb, var(--c) 35%, transparent); }
|
||||
.clickable { cursor: pointer; }
|
||||
.num { font-variant-numeric: tabular-nums; }
|
||||
.head { justify-content: space-between; margin-top: -8px; }
|
||||
.actions { white-space: nowrap; text-align: right; }
|
||||
.group { display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 10px; background: var(--bg-2); }
|
||||
.dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
||||
|
||||
Reference in new issue
Block a user