SCOPENET Map viewer: shared canvas engine, launcher window and claim terrain, admin map card; BlueMap/Vantage UI removed
This commit is contained in:
25 files changed
+1387
-343
No files matched your search
@@ -1,52 +0,0 @@
|
||||
<script lang="ts">
|
||||
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, address = '' }: { id: number; enabled: boolean; address?: string } = $props();
|
||||
let status = $state<LiveMapStatus | null>(null);
|
||||
async function refresh() {
|
||||
try { status = await get<LiveMapStatus>(`/api/admin/servers/${id}/livemap`); }
|
||||
catch (e) { toastError(e); }
|
||||
}
|
||||
onMount(() => {
|
||||
refresh();
|
||||
const timer = setInterval(refresh, 10_000);
|
||||
return () => clearInterval(timer);
|
||||
});
|
||||
</script>
|
||||
|
||||
<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 live player positions in this server’s settings to let players center the map on themselves.</p>
|
||||
{:else if status}
|
||||
<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}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.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>
|
||||
@@ -0,0 +1,120 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { Map as MapIcon, RefreshCw, RotateCcw, Image as ImageIcon, Users } from '@lucide/svelte';
|
||||
import MapViewer from '@scopenet/map/MapViewer.svelte';
|
||||
import { startFeed, type LivePlayer, type MapInfo, type MapOverlay } from '@scopenet/map';
|
||||
import Avatar from './Avatar.svelte';
|
||||
import PlayerLink from './PlayerLink.svelte';
|
||||
import Modal from './Modal.svelte';
|
||||
import { del, formatBytes, get, timeAgo } from '../lib/api';
|
||||
import { toast, toastError } from '../lib/toast.svelte';
|
||||
import type { MapStatus } from '../lib/types';
|
||||
|
||||
let { id, enabled }: { id: number; enabled: boolean } = $props();
|
||||
|
||||
let status = $state<MapStatus | null>(null);
|
||||
let info = $state<MapInfo | null>(null);
|
||||
let overlay = $state<MapOverlay | null>(null);
|
||||
let error = $state('');
|
||||
let resetOpen = $state(false);
|
||||
let resetting = $state(false);
|
||||
let nonce = $state(0);
|
||||
|
||||
const totalTiles = $derived((status?.dimensions ?? []).reduce((a, d) => a + d.tiles, 0));
|
||||
const totalBytes = $derived((status?.dimensions ?? []).reduce((a, d) => a + d.bytes, 0));
|
||||
const tileUrl = (slug: string, z: number, x: number, y: number) => `${info?.tile_base}/${slug}/${z}/${x}/${y}.png?t=${info?.token}`;
|
||||
const avatarUrl = (uuid: string) => `/api/v1/avatar/${uuid}?size=64`;
|
||||
|
||||
async function refresh() {
|
||||
try { status = await get<MapStatus>(`/api/admin/servers/${id}/map`); } catch (e) { toastError(e); }
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
refresh();
|
||||
const timer = setInterval(refresh, 15_000);
|
||||
const stop = enabled ? startFeed({
|
||||
loadInfo: () => get<MapInfo>(`/api/v1/servers/${id}/map`),
|
||||
loadOverlay: () => get<MapOverlay>(`/api/v1/servers/${id}/map/overlay`),
|
||||
onInfo: (i) => { info = i; error = ''; },
|
||||
onOverlay: (o) => { overlay = o; error = ''; },
|
||||
onError: (m) => { error = m; }
|
||||
}) : () => {};
|
||||
return () => { clearInterval(timer); stop(); };
|
||||
});
|
||||
|
||||
async function reset() {
|
||||
resetting = true;
|
||||
try {
|
||||
await del(`/api/admin/servers/${id}/map`);
|
||||
toast('Map cleared. The server will draw it again.');
|
||||
resetOpen = false;
|
||||
info = null; overlay = null; nonce++;
|
||||
await refresh();
|
||||
} catch (e) { toastError(e); } finally { resetting = false; }
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="card map-card">
|
||||
<div class="heading">
|
||||
<h2><MapIcon size={17} /> SCOPENET Map</h2>
|
||||
<span class="pill" class:ok={enabled}>{enabled ? 'On' : 'Off'}</span>
|
||||
<button class="ghost icon" aria-label="Refresh" onclick={refresh}><RefreshCw size={15} /></button>
|
||||
<button class="ghost sm" onclick={() => (resetOpen = true)} disabled={!enabled}><RotateCcw size={14} /> Redraw world</button>
|
||||
</div>
|
||||
|
||||
{#if !enabled}
|
||||
<p class="muted small">The map is off for this server. Switch it on in the server's settings and the plugin or mod starts drawing the world.</p>
|
||||
{:else}
|
||||
<div class="stats">
|
||||
<div><ImageIcon size={14} /><span>Tiles</span><strong>{totalTiles.toLocaleString()}</strong></div>
|
||||
<div><span>Size</span><strong>{formatBytes(totalBytes)}</strong></div>
|
||||
<div><Users size={14} /><span>Online</span><strong>{status?.players ?? 0}</strong></div>
|
||||
<div><span>Last tiles</span><strong>{status?.stats.last_upload_at ? timeAgo(status.stats.last_upload_at) : '—'}</strong></div>
|
||||
<div><span>Last players</span><strong>{status?.stats.last_players_at ? timeAgo(status.stats.last_players_at) : '—'}</strong></div>
|
||||
</div>
|
||||
{#if status?.dimensions.length}
|
||||
<p class="dims muted small">{#each status.dimensions as d, i}{i ? ' · ' : ''}{d.label}: {d.tiles.toLocaleString()} tiles ({formatBytes(d.bytes)}){/each}</p>
|
||||
{/if}
|
||||
<div class="stage">
|
||||
{#key nonce}
|
||||
<MapViewer {info} {overlay} {tileUrl} {avatarUrl} {error}>
|
||||
{#snippet player(p: LivePlayer, close: () => void)}
|
||||
<div class="pcard">
|
||||
<button class="x" onclick={close} aria-label="Close">×</button>
|
||||
<div class="who"><Avatar name={p.name} uuid={p.uuid} size={44} /><div><strong><PlayerLink uuid={p.uuid} name={p.name} /></strong><span>{p.dimension.replace('minecraft:', '').replace('the_', '')}</span></div></div>
|
||||
<p>X {Math.round(p.x)} · Y {Math.round(p.y)} · Z {Math.round(p.z)}</p>
|
||||
<code>/tp {p.name}</code>
|
||||
</div>
|
||||
{/snippet}
|
||||
</MapViewer>
|
||||
{/key}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<Modal bind:open={resetOpen} title="Redraw the world?">
|
||||
<p>This deletes every map tile stored on the panel. The game server notices and draws the world again from its region files, starting near the players. The map stays empty for a while on big worlds.</p>
|
||||
<div class="actions">
|
||||
<button class="ghost" onclick={() => (resetOpen = false)}>Cancel</button>
|
||||
<button class="danger" onclick={reset} disabled={resetting}>{resetting ? 'Clearing…' : 'Clear and redraw'}</button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.map-card { display: flex; flex-direction: column; gap: 0.75rem; }
|
||||
.heading { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.heading h2 { margin: 0 auto 0 0; font-size: 1rem; display: flex; align-items: center; gap: 0.5rem; }
|
||||
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(7.5rem, 1fr)); gap: 0.6rem; }
|
||||
.stats > div { display: flex; flex-direction: column; gap: 2px; padding: 0.55rem 0.75rem; border: 1px solid var(--line); border-radius: 10px; font-size: 0.78rem; color: var(--muted); }
|
||||
.stats strong { color: var(--text); font-size: 1.05rem; }
|
||||
.dims { margin: 0; }
|
||||
.stage { height: min(68vh, 640px); min-height: 380px; border-radius: 14px; overflow: hidden; border: 1px solid var(--line); --map-accent: var(--accent); }
|
||||
.pcard { height: 100%; display: flex; flex-direction: column; gap: 0.5rem; padding: 1rem; border-radius: 18px; background: rgba(14, 19, 34, 0.94); border: 1px solid rgba(255,255,255,0.12); backdrop-filter: blur(14px); color: #f4f6ff; position: relative; align-self: flex-start; height: auto; width: 100%; }
|
||||
.pcard .who { display: flex; gap: 0.7rem; align-items: center; }
|
||||
.pcard .who div { display: flex; flex-direction: column; }
|
||||
.pcard .who span { color: #8d97b8; font-size: 0.8rem; text-transform: capitalize; }
|
||||
.pcard p { margin: 0; color: #d4daf0; font-size: 0.85rem; }
|
||||
.pcard code { align-self: flex-start; padding: 0.25rem 0.5rem; border-radius: 8px; background: rgba(255,255,255,0.08); font-size: 0.8rem; }
|
||||
.pcard .x { position: absolute; top: 8px; right: 10px; width: 26px; height: 26px; border-radius: 50%; border: 0; background: rgba(255,255,255,0.08); color: #fff; cursor: pointer; }
|
||||
.actions { display: flex; justify-content: flex-end; gap: 0.5rem; margin-top: 1rem; }
|
||||
</style>
|
||||
@@ -18,11 +18,10 @@
|
||||
|
||||
<label class="field">Name<input bind:value={draft.name} maxlength="48" placeholder="Survival SMP" /></label>
|
||||
<label class="field">Launcher instance<select bind:value={draft.instance_id}><option value="">Unlinked</option>{#each instances as instance}<option value={instance.id}>{instance.name}</option>{/each}</select></label>
|
||||
<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 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."
|
||||
bind:checked={draft.map_enabled}
|
||||
label="SCOPENET Map"
|
||||
help="The server plugin/mod draws this world in the background and sends only what changed, with players, guild land and shops. Players get a Live Map button in the launcher and you get it here. Nothing else to install or host."
|
||||
/>
|
||||
<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>
|
||||
<div class="field">
|
||||
|
||||
Reference in new issue
Block a user