Rework BlueMap claiming and expand ranks guilds chat and warps

This commit is contained in:
scoped committed 2026-09-30 18:09:18 -04:00
1 parent 434ed5338a
commit 6a2944a88e
68 files changed
+1453 -2500

No files matched your search

+31 -171
View File
@@ -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>
+2 -9
View File
@@ -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>