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

@@ -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>