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
@@ -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>
|
||||
Reference in new issue
Block a user