SCOPENET Map viewer: shared canvas engine, launcher window and claim terrain, admin map card; BlueMap/Vantage UI removed

This commit is contained in:
Claude committed 2026-10-01 01:15:05 +00:00
1 parent 19d111eeff
commit 280f9eaf1b
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>
+120
View File
@@ -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>
+3 -4
View File
@@ -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">
+7 -7
View File
@@ -93,8 +93,7 @@ export interface AuthServerInfo { public_url: string; yggdrasil_url: string; pub
export interface GameServer {
instance_id: string;
live_map_enabled: boolean;
map_address: string;
map_enabled: boolean;
economy_group: string;
id: number;
name: string;
@@ -144,18 +143,19 @@ export interface ServerDetail {
export interface UserActivity { servers: PlayerStats[]; events: ServerEvent[]; online_on: { id: number; name: string }[] }
export interface LiveMapStatus {
/** What the admin map card shows: tiles per dimension plus when the game server last sent things. */
export interface MapStatus {
enabled: boolean;
ready: boolean;
message: string;
dimensions: { id: string; slug: string; label: string; available: boolean }[];
dimensions: { id: string; slug: string; label: string; tiles: number; bytes: number }[];
players: number;
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 };
epoch: number;
stats: { tiles_received: number; bytes_received: number; last_upload_at: string | null; last_players_at: string | null; last_overlay_at: string | null };
roster: { uuid: string; name: string; dimension: string; x: number; y: number; z: number }[];
}
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 type ServerDraft = { instance_id?: string; name: string; map_enabled: boolean; economy_group?: string; access: 'all' | 'members' | 'groups'; allowed_groups: string[]; require_launcher: boolean };
export interface HostedDownload {
platform: 'windows' | 'mac' | 'linux';
+5 -6
View File
@@ -1,12 +1,12 @@
<script lang="ts">
import PlayerLink from '../components/PlayerLink.svelte';
import {
ArrowLeft, Map as MapIcon, Settings2, KeyRound, Trash2, Users, Gauge, Clock, UserRound, LogIn, LogOut, Skull, Award, Sword, MessageSquare,
ArrowLeft, Settings2, KeyRound, Trash2, Users, Gauge, Clock, UserRound, LogIn, LogOut, Skull, Award, Sword, MessageSquare,
CircleDot, Ban, Server,
} from '@lucide/svelte';
import Avatar from '../components/Avatar.svelte';
import IntegrationsPanel from '../components/IntegrationsPanel.svelte';
import LiveMapPanel from '../components/LiveMapPanel.svelte';
import MapPanel from '../components/MapPanel.svelte';
import Modal from '../components/Modal.svelte';
import ServerForm from '../components/ServerForm.svelte';
import TokenReveal from '../components/TokenReveal.svelte';
@@ -21,7 +21,7 @@
let sort = $state('playtime_secs');
let groups = $state<Group[]>([]);
let info = $state<AuthServerInfo | null>(null);
let draft = $state<ServerDraft>({ name: '', access: 'all', allowed_groups: [], require_launcher: false, live_map_enabled: false });
let draft = $state<ServerDraft>({ name: '', access: 'all', allowed_groups: [], require_launcher: false, map_enabled: false });
let editOpen = $state(false);
let token = $state<string | null>(null);
let tokenOpen = $state(false);
@@ -81,7 +81,7 @@
function openEdit() {
if (!s) return;
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 };
draft = { instance_id: s.instance_id, name: s.name, map_enabled: s.map_enabled, economy_group: s.economy_group, access: s.access, allowed_groups: [...s.allowed_groups], require_launcher: s.require_launcher };
editOpen = true;
}
async function saveEdit() {
@@ -130,7 +130,6 @@
</p>
</div>
<div class="row">
{#if s.map_address}<a class="btn" href={s.map_address} target="_blank" rel="noopener noreferrer"><MapIcon size={16} /> Open map</a>{/if}
<button onclick={openEdit}><Settings2 size={16} /> Settings</button>
<button onclick={() => (regenOpen = true)}><KeyRound size={16} /> New token</button>
<button class="ghost icon" aria-label="Remove server" onclick={() => (deleteOpen = true)}><Trash2 size={16} /></button>
@@ -151,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} address={s.map_address} />
<MapPanel id={s.id} enabled={s.map_enabled} />
<IntegrationsPanel id={s.id} />
+2 -2
View File
@@ -11,7 +11,7 @@
let servers = $state<GameServer[] | null>(null);
let groups = $state<Group[]>([]);
let info = $state<AuthServerInfo | null>(null);
let draft = $state<ServerDraft>({ name: '', access: 'all', allowed_groups: [], require_launcher: false, live_map_enabled: true });
let draft = $state<ServerDraft>({ name: '', access: 'all', allowed_groups: [], require_launcher: false, map_enabled: true });
let createOpen = $state(false);
let creating = $state(false);
let created = $state<{ id: number; token: string } | null>(null);
@@ -29,7 +29,7 @@
const online = $derived(servers?.reduce((a, s) => a + s.players, 0) ?? 0);
function openCreate() {
draft = { name: '', access: 'all', allowed_groups: [], require_launcher: false, live_map_enabled: true };
draft = { name: '', access: 'all', allowed_groups: [], require_launcher: false, map_enabled: true };
createOpen = true;
}
+4 -2
View File
@@ -9,7 +9,9 @@
"resolveJsonModule": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"types": ["svelte", "vite/client"]
"types": ["svelte", "vite/client"],
"baseUrl": ".",
"paths": { "svelte": ["./node_modules/svelte"], "svelte/*": ["./node_modules/svelte/*"], "@scopenet/map": ["../../shared/map/index.ts"], "@scopenet/map/*": ["../../shared/map/*"] }
},
"include": ["src/**/*.ts", "src/**/*.svelte"]
"include": ["src/**/*.ts", "src/**/*.svelte", "../../shared/map/**/*.ts", "../../shared/map/**/*.svelte"]
}
+4
View File
@@ -1,12 +1,16 @@
import { defineConfig } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';
import { fileURLToPath } from 'node:url';
// In dev, API calls are proxied to a locally running panel (cargo run -p scopenet-panel).
const target = process.env.PANEL_URL ?? 'http://localhost:8080';
export default defineConfig({
plugins: [svelte()],
// The map viewer is shared with the launcher (repo root: shared/map).
resolve: { dedupe: ['svelte'], alias: { '@scopenet/map': fileURLToPath(new URL('../../shared/map', import.meta.url)) } },
server: {
fs: { allow: ['../..'] },
port: 5173,
proxy: {
'/api': target,