Rework BlueMap claiming and expand ranks guilds chat and warps
This commit is contained in:
1 parent
434ed5338a
commit
6a2944a88e
68 files changed
+1453
-2500
No files matched your search
Generated
+2
-97
@@ -1,16 +1,12 @@
|
||||
{
|
||||
"name": "scopenet-panel-web",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "scopenet-panel-web",
|
||||
"version": "0.4.0",
|
||||
"dependencies": {
|
||||
"@thoughts-on-things/vantage-mc": "^0.15.1",
|
||||
"three": "^0.180.0"
|
||||
},
|
||||
"version": "0.5.0",
|
||||
"devDependencies": {
|
||||
"@fontsource-variable/inter": "^5.3.0",
|
||||
"@fontsource-variable/jetbrains-mono": "^5.3.0",
|
||||
@@ -20,20 +16,12 @@
|
||||
"@lucide/svelte": "^1.48.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.3.1",
|
||||
"@tsconfig/svelte": "^5.0.8",
|
||||
"@types/three": "^0.180.0",
|
||||
"svelte": "^5.57.1",
|
||||
"svelte-check": "^4.7.6",
|
||||
"typescript": "~5.9.0",
|
||||
"vite": "^8.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@dimforge/rapier3d-compat": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
|
||||
"integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@fontsource-variable/inter": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.3.0.tgz",
|
||||
@@ -456,25 +444,6 @@
|
||||
"vite": "^8.0.0-beta.7 || ^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@thoughts-on-things/vantage-mc": {
|
||||
"version": "0.15.1",
|
||||
"resolved": "https://registry.npmjs.org/@thoughts-on-things/vantage-mc/-/vantage-mc-0.15.1.tgz",
|
||||
"integrity": "sha512-muhg9950CgOa8keoCUV+utDHUKhkckwWGVn5O3HFMrgr7OrPo3VpEsBYVSUVL/RjmQDiqEaC19FPLGMTH97jpg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18",
|
||||
"three": ">=0.160"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@tsconfig/svelte": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/svelte/-/svelte-5.0.8.tgz",
|
||||
@@ -482,13 +451,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tweenjs/tween.js": {
|
||||
"version": "23.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
|
||||
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||
@@ -496,43 +458,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/stats.js": {
|
||||
"version": "0.17.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
|
||||
"integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/three": {
|
||||
"version": "0.180.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.180.0.tgz",
|
||||
"integrity": "sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dimforge/rapier3d-compat": "~0.12.0",
|
||||
"@tweenjs/tween.js": "~23.1.3",
|
||||
"@types/stats.js": "*",
|
||||
"@types/webxr": "*",
|
||||
"@webgpu/types": "*",
|
||||
"fflate": "~0.8.2",
|
||||
"meshoptimizer": "~0.22.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/webxr": {
|
||||
"version": "0.5.24",
|
||||
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
|
||||
"integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@webgpu/types": {
|
||||
"version": "0.1.74",
|
||||
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.74.tgz",
|
||||
"integrity": "sha512-lgiI4hbuLcI9unnm2cL/tvCaQU45dp0xcLWJh5uB/9MBGvIA4F8XIk7nSiBeg+K4xWGYwgZKn80LmERI5CxoTA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.18.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
|
||||
@@ -662,13 +587,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fflate": {
|
||||
"version": "0.8.3",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
|
||||
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -972,13 +890,6 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/meshoptimizer": {
|
||||
"version": "0.22.0",
|
||||
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.22.0.tgz",
|
||||
"integrity": "sha512-IebiK79sqIy+E4EgOr+CAw+Ke8hAspXKzBd0JdgEmPHiAwmvEj2S4h1rfvo+o/BnfEYd/jAOg5IeeIjzlzSnDg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mri": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
|
||||
@@ -1204,12 +1115,6 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/three": {
|
||||
"version": "0.180.0",
|
||||
"resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz",
|
||||
"integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.17",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||
|
||||
@@ -18,14 +18,9 @@
|
||||
"@lucide/svelte": "^1.48.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.3.1",
|
||||
"@tsconfig/svelte": "^5.0.8",
|
||||
"@types/three": "^0.180.0",
|
||||
"svelte": "^5.57.1",
|
||||
"svelte-check": "^4.7.6",
|
||||
"typescript": "~5.9.0",
|
||||
"vite": "^8.3.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@thoughts-on-things/vantage-mc": "^0.15.1",
|
||||
"three": "^0.180.0"
|
||||
}
|
||||
}
|
||||
@@ -1,192 +1,52 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import { Map as MapIcon, RefreshCw, Trash2, CircleCheck, CircleAlert, Users, Boxes, Cpu, Package, LoaderCircle } from '@lucide/svelte';
|
||||
import { del, formatBytes, get, timeAgo } from '../lib/api';
|
||||
import { session } from '../lib/session.svelte';
|
||||
import { toast, toastError } from '../lib/toast.svelte';
|
||||
import Modal from './Modal.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { MapPin, RefreshCw, Users } from '@lucide/svelte';
|
||||
import { get, timeAgo } from '../lib/api';
|
||||
import { toastError } from '../lib/toast.svelte';
|
||||
import type { LiveMapStatus } from '../lib/types';
|
||||
|
||||
let { id, enabled }: { id: number; enabled: boolean } = $props();
|
||||
|
||||
let { id, enabled, address = '' }: { id: number; enabled: boolean; address?: string } = $props();
|
||||
let status = $state<LiveMapStatus | null>(null);
|
||||
let dim = $state('overworld');
|
||||
let host = $state<HTMLDivElement>();
|
||||
let viewer: any = null;
|
||||
let loading = $state(false);
|
||||
let viewError = $state('');
|
||||
let resetOpen = $state(false);
|
||||
let loadedKey = '';
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
status = await get<LiveMapStatus>(`/api/admin/servers/${id}/livemap`);
|
||||
if (!status.dimensions.some((d) => d.slug === dim)) dim = status.dimensions.find((d) => d.available)?.slug ?? 'overworld';
|
||||
} catch (e) {
|
||||
toastError(e);
|
||||
}
|
||||
try { status = await get<LiveMapStatus>(`/api/admin/servers/${id}/livemap`); }
|
||||
catch (e) { toastError(e); }
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
void id;
|
||||
void enabled;
|
||||
onMount(() => {
|
||||
refresh();
|
||||
const t = setInterval(refresh, 10000);
|
||||
return () => clearInterval(t);
|
||||
const timer = setInterval(refresh, 10_000);
|
||||
return () => clearInterval(timer);
|
||||
});
|
||||
|
||||
function closeViewer() {
|
||||
viewer?.dispose();
|
||||
viewer = null;
|
||||
loadedKey = '';
|
||||
}
|
||||
|
||||
async function openViewer() {
|
||||
if (!host || !status?.ready) return;
|
||||
const key = `${id}:${dim}`;
|
||||
if (loadedKey === key && viewer) return;
|
||||
closeViewer();
|
||||
loading = true;
|
||||
viewError = '';
|
||||
try {
|
||||
const [{ VantageViewer }, { worldFromHttp }] = await Promise.all([
|
||||
import('@thoughts-on-things/vantage-mc/three'),
|
||||
import('@thoughts-on-things/vantage-mc/core'),
|
||||
]);
|
||||
const url = `${location.origin}${status.base}/${dim}/v1/worlds/default/manifest.json`;
|
||||
const world = await worldFromHttp(url, { accessToken: session.token ?? undefined });
|
||||
const v = new VantageViewer(host, { view: 'top', players: { offline: false } });
|
||||
try {
|
||||
await v.load({ world, view: 'top' });
|
||||
} catch (e) {
|
||||
v.dispose();
|
||||
throw e;
|
||||
}
|
||||
viewer = v;
|
||||
loadedKey = key;
|
||||
} catch (e) {
|
||||
viewError = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Reload whenever the dimension changes or the map becomes ready.
|
||||
$effect(() => {
|
||||
void dim;
|
||||
if (status?.ready && host) openViewer();
|
||||
});
|
||||
onDestroy(closeViewer);
|
||||
|
||||
async function reset() {
|
||||
try {
|
||||
await del(`/api/admin/servers/${id}/livemap`);
|
||||
closeViewer();
|
||||
resetOpen = false;
|
||||
toast('Live map data cleared — the server will re-upload its world');
|
||||
refresh();
|
||||
} catch (e) {
|
||||
toastError(e);
|
||||
}
|
||||
}
|
||||
|
||||
const totalRegions = $derived(status?.world.dimensions.reduce((n, d) => n + d.regions, 0) ?? 0);
|
||||
const totalBytes = $derived(status?.world.dimensions.reduce((n, d) => n + d.bytes, 0) ?? 0);
|
||||
</script>
|
||||
|
||||
<section class="card live">
|
||||
<div class="section-title">
|
||||
<h2><MapIcon size={17} /> Live Map</h2>
|
||||
<span class="pill" class:ok={status?.ready} class:off={!enabled}>{!enabled ? 'Off' : status?.ready ? 'Live' : 'Setting up'}</span>
|
||||
<button class="ghost icon" aria-label="Refresh" onclick={refresh}><RefreshCw size={15} /></button>
|
||||
<section class="card relay">
|
||||
<div class="heading">
|
||||
<h2><MapPin size={17} /> Live player positions</h2>
|
||||
<span class="pill" class:ok={enabled}>{enabled ? 'On' : 'Off'}</span>
|
||||
<button class="ghost icon" aria-label="Refresh positions" onclick={refresh}><RefreshCw size={15} /></button>
|
||||
</div>
|
||||
|
||||
<p class="muted small">{address ? 'The launcher uses this server’s BlueMap for 2D terrain and these positions to center each player’s claim map.' : 'Add a BlueMap address to display 2D terrain in the launcher.'}</p>
|
||||
{#if !enabled}
|
||||
<p class="muted small">Turn on <strong>Live Map</strong> in this server's settings. The SCOPENET plugin/mod will then send chunks and player positions here, and launchers get a Live Map button.</p>
|
||||
<p class="muted small">Turn on live player positions in this server’s settings to let players center the map on themselves.</p>
|
||||
{:else if status}
|
||||
<div class="checks">
|
||||
<div class="check" class:good={status.generator.installed}>
|
||||
{#if status.generator.installed}<CircleCheck size={16} />{:else}<CircleAlert size={16} />{/if}
|
||||
<span><Cpu size={13} /> Vantage generator</span>
|
||||
<small>{status.generator.installed ? status.generator.path : 'Not installed on the panel'}</small>
|
||||
</div>
|
||||
<div class="check" class:good={status.assets.configured}>
|
||||
{#if status.assets.configured}<CircleCheck size={16} />{:else}<CircleAlert size={16} />{/if}
|
||||
<span><Package size={13} /> Minecraft assets</span>
|
||||
<small>{status.assets.configured ? status.assets.path : 'Set SCOPENET_VANTAGE_ASSETS'}</small>
|
||||
</div>
|
||||
<div class="check" class:good={status.world.level_dat && totalRegions > 0}>
|
||||
{#if status.world.level_dat && totalRegions > 0}<CircleCheck size={16} />{:else}<CircleAlert size={16} />{/if}
|
||||
<span><Boxes size={13} /> World data</span>
|
||||
<small>{totalRegions ? `${totalRegions} regions · ${formatBytes(totalBytes)} · ${status.stats.chunks_received.toLocaleString()} chunks this session` : 'Waiting for the server to upload'}</small>
|
||||
</div>
|
||||
<div class="check" class:good={status.players > 0}>
|
||||
{#if status.players > 0}<CircleCheck size={16} />{:else}<CircleAlert size={16} />{/if}
|
||||
<span><Users size={13} /> Player positions</span>
|
||||
<small>{status.stats.last_players_at ? `${status.players} live · updated ${timeAgo(status.stats.last_players_at)}` : 'Nothing received yet'}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if !status.generator.installed || !status.assets.configured}
|
||||
<p class="hint-box small">
|
||||
The panel stores what the server sends, but rendering needs the Vantage generator and Minecraft client assets.
|
||||
Install them as described in <code>docs/vantage-setup.md</code> — until then launchers show a “map is being set up” message.
|
||||
</p>
|
||||
{/if}
|
||||
{#if status.last_error}<p class="err small">Generator: {status.last_error}</p>{/if}
|
||||
|
||||
{#if status.ready}
|
||||
<div class="tabs">
|
||||
{#each status.dimensions as d (d.slug)}
|
||||
<button class="tab" class:on={dim === d.slug} disabled={!d.available} onclick={() => (dim = d.slug)}>{d.label}</button>
|
||||
<p class="summary"><Users size={15} /> {status.players} online {status.players === 1 ? 'position' : 'positions'}
|
||||
{#if status.stats.last_players_at}<span class="muted small">· updated {timeAgo(status.stats.last_players_at)}</span>{/if}
|
||||
</p>
|
||||
{#if status.roster.length}
|
||||
<div class="roster">
|
||||
{#each status.roster as player (player.uuid)}
|
||||
<span>{player.name} <small>{player.dimension.replace('minecraft:', '')} · {Math.floor(player.x)}, {Math.floor(player.z)}</small></span>
|
||||
{/each}
|
||||
<span class="grow"></span>
|
||||
<button class="ghost" onclick={() => (resetOpen = true)}><Trash2 size={14} /> Reset map data</button>
|
||||
</div>
|
||||
<div class="stage">
|
||||
<div class="viewer" bind:this={host}></div>
|
||||
{#if loading}<div class="overlay"><LoaderCircle class="spin" size={22} /> Rendering terrain…</div>{/if}
|
||||
{#if viewError}<div class="overlay err">Map unavailable: {viewError}</div>{/if}
|
||||
</div>
|
||||
{:else if status.message}
|
||||
<p class="muted small">{status.message}</p>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="skeleton"></div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<Modal bind:open={resetOpen} title="Reset map data?">
|
||||
<p class="muted">Deletes the mirrored world and rendered tiles for this server. The game server uploads everything again, which can take a while for big worlds.</p>
|
||||
{#snippet footer()}
|
||||
<button class="ghost" onclick={() => (resetOpen = false)}>Cancel</button>
|
||||
<button class="danger" onclick={reset}><Trash2 size={16} /> Reset</button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.live { display: flex; flex-direction: column; gap: 14px; margin-bottom: 16px; }
|
||||
h2 { display: flex; align-items: center; gap: 8px; }
|
||||
.pill { margin-left: auto; padding: 3px 11px; border-radius: 99px; font-size: 0.75rem; font-weight: 600; background: color-mix(in srgb, #fcd34d 16%, transparent); color: #fcd34d; }
|
||||
.pill.ok { background: color-mix(in srgb, #6ee7b7 16%, transparent); color: #6ee7b7; }
|
||||
.pill.off { background: var(--bg-2); color: var(--muted); }
|
||||
.checks { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 10px; }
|
||||
.check { display: grid; grid-template-columns: 18px 1fr; gap: 2px 10px; align-items: center; padding: 11px 13px; border-radius: 12px; background: var(--bg-2); color: #fcd34d; }
|
||||
.check.good { color: #6ee7b7; }
|
||||
.check span { color: var(--text); font-size: 0.85rem; font-weight: 560; display: inline-flex; align-items: center; gap: 6px; }
|
||||
.check span :global(svg) { color: var(--muted); }
|
||||
.check small { grid-column: 2; color: var(--muted); font-size: 0.75rem; overflow-wrap: anywhere; }
|
||||
.hint-box { padding: 11px 14px; border-radius: 12px; background: color-mix(in srgb, var(--accent) 8%, var(--bg-2)); color: var(--text-2); line-height: 1.5; }
|
||||
.err { color: #fda4af; }
|
||||
.tabs { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
|
||||
.tab { padding: 7px 14px; border-radius: 99px; font-size: 0.85rem; background: var(--bg-2); }
|
||||
.tab.on { border-color: color-mix(in srgb, var(--accent) 60%, transparent); background: var(--accent-soft); }
|
||||
.tab:disabled { opacity: 0.4; }
|
||||
.grow { flex: 1; }
|
||||
.stage { position: relative; border-radius: 14px; overflow: hidden; background: #102026; height: min(62vh, 620px); min-height: 380px; }
|
||||
.viewer { position: absolute; inset: 0; }
|
||||
.viewer :global(canvas) { display: block; }
|
||||
.overlay { position: absolute; inset: 0; display: grid; place-items: center; grid-auto-flow: column; gap: 10px; justify-content: center; color: #e5e7eb; background: rgba(16, 32, 38, 0.72); pointer-events: none; }
|
||||
.overlay.err { color: #fda4af; text-align: center; padding: 24px; }
|
||||
.skeleton { height: 120px; border-radius: 12px; background: var(--bg-2); }
|
||||
.relay { display: flex; flex-direction: column; gap: 0.6rem; }
|
||||
.heading, .heading h2, .summary { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.heading h2 { margin: 0 auto 0 0; font-size: 1rem; }
|
||||
.summary { margin: 0; }
|
||||
.roster { display: flex; flex-wrap: wrap; gap: 0.5rem; }
|
||||
.roster > span { display: flex; flex-direction: column; gap: 2px; padding: 0.5rem 0.7rem; border: 1px solid var(--line); border-radius: 8px; font-size: 0.83rem; }
|
||||
.roster small { color: var(--muted); }
|
||||
</style>
|
||||
@@ -21,14 +21,10 @@
|
||||
<label class="field">Map address (BlueMap)<input type="url" bind:value={draft.map_address} placeholder="https://map.example.com" autocomplete="off" /><span class="tiny muted">Where this server's BlueMap is served. Players get a <strong>Live Map</strong> button in the launcher that shows it inside the app, exactly as in a browser. Install BlueMap on the server, then leave this empty to hide the button.</span></label>
|
||||
<Toggle
|
||||
bind:checked={draft.live_map_enabled}
|
||||
label="Live Map"
|
||||
help="The SCOPENET plugin/mod sends this server's chunks and player positions to the panel, which hosts a Vantage 3D map. Launchers show a Live Map button — nothing to run next to the server."
|
||||
label="Live player positions"
|
||||
help="The server plugin/mod sends live player positions to the panel so the launcher can center the BlueMap territory view on the player."
|
||||
/>
|
||||
<label class="field">Shared economy group<input bind:value={draft.economy_group} maxlength="32" placeholder="Leave empty for a separate economy" /><span class="tiny muted">Servers with the same group name share player balances and guild banks, so one wallet follows players across your network. Existing per-server balances aren't merged.</span></label>
|
||||
<details class="adv">
|
||||
<summary>Use an external Vantage map instead</summary>
|
||||
<label class="field">Vantage manifest URL<input type="url" bind:value={draft.map_url} placeholder="https://maps.example.com/world.json" /><span class="tiny muted">Optional and only used when Live Map above is off. The URL is visible to launcher users.</span></label>
|
||||
</details>
|
||||
<div class="field">
|
||||
<span class="lbl">Who can join</span>
|
||||
<div class="opts">
|
||||
@@ -68,8 +64,5 @@
|
||||
.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); }
|
||||
.adv { border: 1px dashed var(--line, rgba(255, 255, 255, 0.12)); border-radius: 10px; padding: 10px 12px; }
|
||||
.adv summary { cursor: pointer; font-size: 0.85rem; color: var(--muted); }
|
||||
.adv[open] summary { margin-bottom: 10px; }
|
||||
.dot { width: 10px; height: 10px; border-radius: 50%; }
|
||||
</style>
|
||||
@@ -93,7 +93,6 @@ export interface AuthServerInfo { public_url: string; yggdrasil_url: string; pub
|
||||
|
||||
export interface GameServer {
|
||||
instance_id: string;
|
||||
map_url: string;
|
||||
live_map_enabled: boolean;
|
||||
map_address: string;
|
||||
economy_group: string;
|
||||
@@ -149,19 +148,14 @@ export interface LiveMapStatus {
|
||||
enabled: boolean;
|
||||
ready: boolean;
|
||||
message: string;
|
||||
base: string;
|
||||
dimensions: { id: string; slug: string; label: string; available: boolean }[];
|
||||
players: number;
|
||||
generator: { installed: boolean; path: string | null };
|
||||
assets: { configured: boolean; path: string | null };
|
||||
world: { level_dat: boolean; dimensions: { id: string; slug: string; regions: number; bytes: number }[] };
|
||||
stats: { chunks_received: number; last_chunk_at: string | null; last_players_at: string | null };
|
||||
running: string[];
|
||||
last_error: string | null;
|
||||
roster: { uuid: string; name: string; dimension: string; x: number; y: number; z: number }[];
|
||||
}
|
||||
|
||||
export type ServerDraft = { instance_id?: string; name: string; map_url?: string; live_map_enabled: boolean; economy_group?: string; map_address?: string; access: 'all' | 'members' | 'groups'; allowed_groups: string[]; require_launcher: boolean };
|
||||
export type ServerDraft = { instance_id?: string; name: string; live_map_enabled: boolean; economy_group?: string; map_address?: string; access: 'all' | 'members' | 'groups'; allowed_groups: string[]; require_launcher: boolean };
|
||||
|
||||
export interface HostedDownload {
|
||||
platform: 'windows' | 'mac' | 'linux';
|
||||
@@ -216,6 +210,8 @@ export interface LevelReward {
|
||||
reward_value: string;
|
||||
description: string;
|
||||
icon?: string;
|
||||
discord_role_id?: string;
|
||||
luckperms_group?: string;
|
||||
}
|
||||
|
||||
export interface UserLevelInfo {
|
||||
|
||||
@@ -8,6 +8,16 @@
|
||||
let stats = $state<any>(null);
|
||||
let instances = $state<any[]>([]);
|
||||
let branding = $state<any>(null);
|
||||
let showGettingStarted = $state(localStorage.getItem('scopenet_hide_getting_started') !== 'true');
|
||||
|
||||
function hideGettingStarted() {
|
||||
showGettingStarted = false;
|
||||
localStorage.setItem('scopenet_hide_getting_started', 'true');
|
||||
}
|
||||
function showGuide() {
|
||||
showGettingStarted = true;
|
||||
localStorage.removeItem('scopenet_hide_getting_started');
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
get('/api/admin/stats').then((s) => (stats = s));
|
||||
@@ -38,6 +48,7 @@
|
||||
{#if stats?.launcher_download_url}
|
||||
<a class="btn primary" href={stats.launcher_download_url} target="_blank" rel="noreferrer"><Download size={16} /> Launcher download</a>
|
||||
{/if}
|
||||
{#if !showGettingStarted}<button class="ghost" onclick={showGuide}>Show getting started</button>{/if}
|
||||
</div>
|
||||
|
||||
<div class="grid stats">
|
||||
@@ -69,8 +80,8 @@
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="section-title"><h2>Getting started</h2></div>
|
||||
{#if showGettingStarted}<section class="card">
|
||||
<div class="section-title"><h2>Getting started</h2><button class="hint" onclick={hideGettingStarted} aria-label="Hide Getting started">Hide</button></div>
|
||||
<div class="steps">
|
||||
{#each steps as s}
|
||||
<a class="step" class:done={s.done} href={s.href}>
|
||||
@@ -80,7 +91,7 @@
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
</section>{/if}
|
||||
|
||||
{#if stats?.live?.servers?.length}
|
||||
<section class="card servers">
|
||||
|
||||
@@ -38,6 +38,8 @@
|
||||
reward_value: x.reward_value ?? x.reward_name ?? '',
|
||||
description: x.description ?? '',
|
||||
icon: x.icon ?? '🎁',
|
||||
discord_role_id: x.reward_data?.discord_role_id ?? '',
|
||||
luckperms_group: x.reward_data?.luckperms_group ?? '',
|
||||
})).sort((a, b) => a.level - b.level);
|
||||
leaderboard = lb || [];
|
||||
const p = await get<{ settings: { level_base: number; level_exponent: number; max_level: number } }>('/api/admin/progression');
|
||||
@@ -77,10 +79,10 @@
|
||||
saving = true;
|
||||
try {
|
||||
if (draft.id) {
|
||||
await put(`/api/admin/levels/rewards/${draft.id}`, draft);
|
||||
await put(`/api/admin/levels/rewards/${draft.id}`, { ...draft, reward_data: { discord_role_id: draft.discord_role_id ?? '', luckperms_group: draft.luckperms_group ?? '' } });
|
||||
toast('Reward updated');
|
||||
} else {
|
||||
await post('/api/admin/levels/rewards', draft);
|
||||
await post('/api/admin/levels/rewards', { ...draft, reward_data: { discord_role_id: draft.discord_role_id ?? '', luckperms_group: draft.luckperms_group ?? '' } });
|
||||
toast('Reward created');
|
||||
}
|
||||
draftOpen = false;
|
||||
@@ -209,7 +211,7 @@
|
||||
<div class="grid-2">
|
||||
<div class="field">
|
||||
<label for="r-lvl">Target Global Level</label>
|
||||
<input id="r-lvl" type="number" min="2" max="500" bind:value={draft.level} required />
|
||||
<input id="r-lvl" type="number" min="1" max="500" bind:value={draft.level} required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="r-type">Reward Type</label>
|
||||
@@ -236,6 +238,12 @@
|
||||
<label for="r-desc">Description</label>
|
||||
<textarea id="r-desc" rows="3" bind:value={draft.description} required placeholder="Describe what the player unlocks..."></textarea>
|
||||
</div>
|
||||
{#if draft.reward_type === 'title'}
|
||||
<div class="grid-2">
|
||||
<div class="field"><label>Discord role ID<input bind:value={draft.discord_role_id} inputmode="numeric" placeholder="Optional role for this rank" /></label></div>
|
||||
<div class="field"><label>LuckPerms group<input bind:value={draft.luckperms_group} placeholder="Optional in-game group" /></label></div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="ghost" onclick={() => (draftOpen = false)}>Cancel</button>
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
|
||||
function openEdit() {
|
||||
if (!s) return;
|
||||
draft = { instance_id: s.instance_id, name: s.name, map_url: s.map_url, live_map_enabled: s.live_map_enabled, economy_group: s.economy_group, map_address: s.map_address, access: s.access, allowed_groups: [...s.allowed_groups], require_launcher: s.require_launcher };
|
||||
draft = { instance_id: s.instance_id, name: s.name, live_map_enabled: s.live_map_enabled, economy_group: s.economy_group, map_address: s.map_address, access: s.access, allowed_groups: [...s.allowed_groups], require_launcher: s.require_launcher };
|
||||
editOpen = true;
|
||||
}
|
||||
async function saveEdit() {
|
||||
@@ -150,7 +150,7 @@
|
||||
<div class="card tile"><span class="lbl"><Clock size={14} /> Total playtime</span><span class="val">{duration(d.totals.playtime_secs)}</span></div>
|
||||
</div>
|
||||
|
||||
<LiveMapPanel id={s.id} enabled={s.live_map_enabled} />
|
||||
<LiveMapPanel id={s.id} enabled={s.live_map_enabled} address={s.map_address} />
|
||||
|
||||
<IntegrationsPanel id={s.id} />
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
async function saveDisc() {
|
||||
savingDisc = true;
|
||||
try {
|
||||
disc = await put('/api/admin/discord', { role_sync: disc.role_sync, invite_url: disc.invite_url, webhook_url: discWebhook, notify_achievements: disc.notify_achievements, notify_guilds: disc.notify_guilds, notify_members: disc.notify_members });
|
||||
disc = await put('/api/admin/discord', { role_sync: disc.role_sync, base_role_id: disc.base_role_id, invite_url: disc.invite_url, webhook_url: discWebhook, notify_achievements: disc.notify_achievements, notify_guilds: disc.notify_guilds, notify_members: disc.notify_members });
|
||||
discWebhook = '';
|
||||
toast('Discord community settings saved');
|
||||
loadDisc();
|
||||
@@ -203,7 +203,7 @@
|
||||
{#if disc}
|
||||
<section class="card col">
|
||||
<div class="section-title"><KeyRound size={18} /><h2>Discord community</h2></div>
|
||||
<p class="help">{disc.linked_accounts} linked account{disc.linked_accounts === 1 ? '' : 's'}. Role sync needs the bot token and server ID above{disc.bot_ready ? '' : ' (not saved yet)'}. Sync only ever <em>adds</em>: it never removes a role or group. Map roles to groups under Players → Groups.</p>
|
||||
<p class="help">{disc.linked_accounts} linked account{disc.linked_accounts === 1 ? '' : 's'}. Role sync needs the bot token and server ID above{disc.bot_ready ? '' : ' (not saved yet)'}. Map group roles under Players → Groups and title roles under Leveling. The highest earned title role takes priority; obsolete title roles are removed.</p>
|
||||
<label class="field">Role sync
|
||||
<select bind:value={disc.role_sync}>
|
||||
<option value="off">Off</option>
|
||||
@@ -212,6 +212,7 @@
|
||||
<option value="both">Both directions</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">Base level Discord role ID<input bind:value={disc.base_role_id} inputmode="numeric" placeholder="Optional role for new players" /></label>
|
||||
<label class="field">Invite link shown in the launcher<input bind:value={disc.invite_url} placeholder="https://discord.gg/yourcode" autocomplete="off" /></label>
|
||||
<label class="field">Announcements webhook<input type="password" bind:value={discWebhook} placeholder={disc.webhook_set ? 'Saved — type to replace, or - to remove' : 'https://discord.com/api/webhooks/…'} autocomplete="off" /></label>
|
||||
<Toggle bind:checked={disc.notify_achievements} label="Announce achievements" />
|
||||
|
||||
Reference in new issue
Block a user