Launcher: BlueMap view with player side panel, map actions, guild invitations

This commit is contained in:
Claude committed 2026-09-30 20:15:32 +00:00
1 parent 878827625a
commit e160ad8da3
10 files changed
+342 -7

No files matched your search

+48
View File
@@ -1190,3 +1190,51 @@ pub async fn like_user_post(state: State<'_, AppState>, post_id: i64) -> Res<boo
let body: serde_json::Value = resp.json().await.map_err(err)?; let body: serde_json::Value = resp.json().await.map_err(err)?;
Ok(body["liked"].as_bool().unwrap_or(false)) Ok(body["liked"].as_bool().unwrap_or(false))
} }
// ---------------------------------------------------------------------------
// Map actions and guild invitations
// ---------------------------------------------------------------------------
async fn ok_or_panel_error(resp: reqwest::Response, fallback: &str) -> Res<serde_json::Value> {
if resp.status().is_success() {
return Ok(resp.json().await.unwrap_or(serde_json::Value::Null));
}
let body: serde_json::Value = resp.json().await.unwrap_or_default();
Err(body["error"].as_str().or(body["message"].as_str()).unwrap_or(fallback).to_string())
}
/// From the map: ask a player to teleport to, or send them a message in game.
#[tauri::command]
pub async fn map_action(state: State<'_, AppState>, server_id: i64, action: String, target_uuid: String, text: Option<String>) -> Res<()> {
let req = account_api(&state, reqwest::Method::POST, &format!("/servers/{server_id}/map/actions")).await?;
let resp = req
.json(&serde_json::json!({ "action": action, "target_uuid": target_uuid, "text": text.unwrap_or_default() }))
.send()
.await
.map_err(err)?;
ok_or_panel_error(resp, "unable to do that right now").await.map(|_| ())
}
/// Invite a player to a guild you lead or officiate.
#[tauri::command]
pub async fn guild_send_invite(state: State<'_, AppState>, guild_id: String, target_uuid: String) -> Res<serde_json::Value> {
let req = account_api(&state, reqwest::Method::POST, &format!("/guilds/{guild_id}/invites")).await?;
let resp = req.json(&serde_json::json!({ "uuid": target_uuid })).send().await.map_err(err)?;
ok_or_panel_error(resp, "unable to send the invitation").await
}
#[tauri::command]
pub async fn my_guild_invites(state: State<'_, AppState>) -> Res<serde_json::Value> {
let req = account_api(&state, reqwest::Method::GET, "/guilds/invites").await?;
let resp = req.send().await.map_err(err)?;
ok_or_panel_error(resp, "unable to load invitations").await
}
#[tauri::command]
pub async fn respond_guild_invite(state: State<'_, AppState>, invite_id: i64, accept: bool) -> Res<serde_json::Value> {
let path = format!("/guilds/invites/{invite_id}/{}", if accept { "accept" } else { "decline" });
let req = account_api(&state, reqwest::Method::POST, &path).await?;
let resp = req.send().await.map_err(err)?;
ok_or_panel_error(resp, "unable to answer the invitation").await
}
+4
View File
@@ -58,6 +58,10 @@ pub fn run() {
commands::request_password_reset, commands::request_password_reset,
commands::discord_connection, commands::discord_connection,
commands::discord_info, commands::discord_info,
commands::map_action,
commands::guild_send_invite,
commands::my_guild_invites,
commands::respond_guild_invite,
commands::discord_link_start, commands::discord_link_start,
commands::discord_unlink, commands::discord_unlink,
commands::register_panel, commands::register_panel,
+1 -1
View File
@@ -27,7 +27,7 @@
} }
], ],
"security": { "security": {
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' https: http: data: blob:; media-src 'self' https: http: blob:; font-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost" "csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' https: http: data: blob:; media-src 'self' https: http: blob:; font-src 'self' data:; frame-src https: http:; connect-src 'self' ipc: http://ipc.localhost"
} }
}, },
"bundle": { "bundle": {
@@ -0,0 +1,80 @@
<script lang="ts">
import { onMount } from 'svelte';
import { X, ExternalLink, RefreshCw, LoaderCircle } from '@lucide/svelte';
import { openUrl } from '../lib/tauri';
import MapPlayerPanel from './MapPlayerPanel.svelte';
let { serverId, serverName, address, instanceId, onclose }: { serverId: number; serverName: string; address: string; instanceId: string; onclose: () => void } = $props();
let frame = $state<HTMLIFrameElement>();
let loaded = $state(false);
let selected = $state<{ uuid: string; name: string } | null>(null);
let nonce = $state(0);
const origin = $derived.by(() => { try { return new URL(address).origin; } catch { return ''; } });
// The map page (BlueMap with SCOPENET's script) reports which player was clicked. Only that page may talk to us.
function onMessage(e: MessageEvent) {
if (!frame || e.source !== frame.contentWindow || e.origin !== origin) return;
const m = e.data;
if (!m || m.source !== 'scopenet-map') return;
if (m.type === 'ready') frame.contentWindow?.postMessage({ source: 'scopenet-launcher', type: 'hello' }, origin);
else if (m.type === 'player' && typeof m.uuid === 'string' && /^[0-9a-fA-F-]{32,36}$/.test(m.uuid)) selected = { uuid: m.uuid, name: String(m.name ?? '').slice(0, 32) };
else if (m.type === 'deselect') selected = null;
}
onMount(() => {
window.addEventListener('message', onMessage);
return () => window.removeEventListener('message', onMessage);
});
const reload = () => { loaded = false; selected = null; nonce++; };
</script>
<svelte:window onkeydown={(e) => e.key === 'Escape' && (selected ? (selected = null) : onclose())} />
<div class="overlay" role="dialog" aria-modal="true" aria-label="Live map">
<header class="glass">
<div class="title">
<span class="live" class:on={loaded}></span>
<div><strong>{serverName}</strong><span class="muted">Live Map</span></div>
</div>
<span class="grow"></span>
<button class="ghost sm" onclick={reload} title="Reload the map"><RefreshCw size={14} /> Reload</button>
<button class="ghost sm" onclick={() => openUrl(address)} title="Open the same map in your browser"><ExternalLink size={14} /> Browser</button>
<button class="ghost icon close" onclick={onclose} aria-label="Close live map"><X size={18} /></button>
</header>
<div class="stage">
{#key nonce}
<iframe
bind:this={frame}
title="{serverName} live map"
src={address}
sandbox="allow-scripts allow-same-origin allow-popups allow-forms allow-pointer-lock"
allow="fullscreen; clipboard-write"
referrerpolicy="no-referrer"
onload={() => (loaded = true)}
></iframe>
{/key}
{#if !loaded}<div class="loading"><LoaderCircle class="spin" size={24} /> Loading the map…</div>{/if}
{#if selected}
<MapPlayerPanel {serverId} {instanceId} uuid={selected.uuid} nameHint={selected.name} onclose={() => (selected = null)} />
{/if}
</div>
</div>
<style>
.overlay { position: fixed; inset: 0; z-index: 90; display: flex; flex-direction: column; gap: 0.75rem; padding: 0.9rem; background: color-mix(in srgb, var(--bg) 88%, transparent); backdrop-filter: blur(10px); animation: fade 0.15s ease; }
header { display: flex; align-items: center; gap: 0.6rem; padding: 0.55rem 0.7rem 0.55rem 1rem; background: var(--surface); flex-shrink: 0; }
.title { display: flex; align-items: center; gap: 0.7rem; }
.title div { display: flex; flex-direction: column; line-height: 1.2; }
.title .muted { font-size: 0.75rem; }
.live { width: 0.6rem; height: 0.6rem; border-radius: 50%; background: color-mix(in srgb, var(--muted) 55%, transparent); }
.live.on { background: #34d399; box-shadow: 0 0 0 4px color-mix(in srgb, #34d399 22%, transparent); }
.grow { flex: 1; }
.sm { display: inline-flex; align-items: center; gap: 0.35rem; }
.stage { position: relative; flex: 1; min-height: 0; border-radius: var(--radius); overflow: hidden; border: 1px solid var(--line); background: #0b0d12; }
iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; }
.loading { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; gap: 0.6rem; color: var(--muted); pointer-events: none; }
</style>
@@ -0,0 +1,164 @@
<script lang="ts">
import { onMount } from 'svelte';
import { X, Send, UserPlus, Sparkles, MapPin, LoaderCircle, Shield, Clock, Swords, Star } from '@lucide/svelte';
import Avatar from './Avatar.svelte';
import { activeAccount, toast } from '../lib/store.svelte';
import { invoke } from '../lib/tauri';
import type { Guild, GuildMember, UserProfileView } from '../lib/types';
let { serverId, instanceId, uuid, nameHint, onclose }: { serverId: number; instanceId: string; uuid: string; nameHint: string; onclose: () => void } = $props();
let profile = $state<UserProfileView | null>(null);
let loading = $state(true);
let failed = $state('');
let message = $state('');
let busy = $state('');
let myGuild = $state<Guild | null>(null);
let iCanInvite = $state(false);
const me = $derived(activeAccount());
// Acting on someone needs a panel account; offline accounts can still look.
const signedIn = $derived(me?.kind === 'panel');
const isMe = $derived(me?.uuid?.toLowerCase() === uuid.toLowerCase());
async function load() {
loading = true;
failed = '';
try {
profile = await invoke<UserProfileView>('get_user_profile', { uuid });
} catch (e: any) {
failed = typeof e === 'string' ? e : e?.message ?? 'Could not load this player';
} finally {
loading = false;
}
if (signedIn) {
try {
myGuild = await invoke<Guild | null>('get_my_guild', { instanceId });
if (myGuild) {
const members = await invoke<GuildMember[]>('get_guild_members', { guildId: myGuild.id });
const mine = members.find((m) => m.uuid.toLowerCase() === me?.uuid?.toLowerCase());
iCanInvite = mine?.role === 'leader' || mine?.role === 'officer';
}
} catch { /* invites just stay off */ }
}
}
// A new click on the map loads the new player.
$effect(() => { void uuid; message = ''; load(); });
onMount(() => { const t = setInterval(() => { if (!busy) load(); }, 20000); return () => clearInterval(t); });
async function run(kind: string, job: () => Promise<unknown>, done: string) {
busy = kind;
try { await job(); toast(done, 'ok'); }
catch (e: any) { toast(typeof e === 'string' ? e : e?.message ?? 'That did not work', 'error'); }
finally { busy = ''; }
}
const tpa = () => run('tpa', () => invoke('map_action', { serverId, action: 'tpa', targetUuid: uuid, text: null }), 'Teleport request sent');
const invite = () => run('invite', () => invoke('guild_send_invite', { guildId: myGuild!.id, targetUuid: uuid }), `Invitation sent to ${profile?.username ?? nameHint}`);
async function send() {
const text = message.trim();
if (!text) return;
await run('message', () => invoke('map_action', { serverId, action: 'message', targetUuid: uuid, text }), 'Message sent');
message = '';
}
const hours = (s: number) => (s >= 3600 ? `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m` : `${Math.floor(s / 60)}m`);
const name = $derived(profile?.username ?? nameHint ?? 'Player');
const online = $derived(!!profile?.online);
const kd = $derived.by(() => {
const k = Number(profile?.stats?.player_kills ?? 0), d = Number(profile?.stats?.deaths ?? 0);
return d === 0 ? (k ? `${k}.0` : '–') : (k / d).toFixed(2);
});
const canAct = $derived(signedIn && !isMe && online);
</script>
<aside class="panel glass" style:--pa={profile?.accent_color || 'var(--accent)'} aria-label="Player">
<button class="ghost icon close" onclick={onclose} aria-label="Close"><X size={16} /></button>
{#if loading && !profile}
<div class="center"><LoaderCircle class="spin" size={24} /></div>
{:else if failed && !profile}
<p class="muted pad">{failed}</p>
{:else if profile}
<div class="head">
<div class="ring"><Avatar {uuid} name={name} size={4} /></div>
<div class="who">
<h3>{name}</h3>
{#if profile.level_info?.title}<span class="title"><Sparkles size={12} /> {profile.level_info.title}</span>{/if}
<span class="presence"><i class:on={online}></i>{online ? 'Online now' : 'Offline'}</span>
</div>
</div>
<div class="chips">
<span class="chip lv"><Star size={12} /> Level {profile.level_info?.level ?? 1}</span>
{#if profile.guild}<span class="chip guild"><Shield size={12} /> [{profile.guild.tag}] {profile.guild.name}</span>{/if}
{#if profile.rank}<span class="chip rank">{profile.rank.display}</span>{/if}
</div>
<div class="facts">
<div><Clock size={13} /><span>Playtime</span><strong>{hours(profile.stats?.playtime_secs ?? 0)}</strong></div>
<div><Swords size={13} /><span>K/D</span><strong>{kd}</strong></div>
<div><MapPin size={13} /><span>Favourite</span><strong>{profile.favorite_server ?? '–'}</strong></div>
<div><Star size={13} /><span>Awards</span><strong>{profile.achievements_count ?? 0}</strong></div>
</div>
{#if profile.bio}<p class="bio">{profile.bio}</p>{/if}
<div class="actions">
{#if isMe}
<p class="muted small">This is you.</p>
{:else if !signedIn}
<p class="muted small">Sign in with a SCOPENET account to send requests and messages.</p>
{:else if !online}
<p class="muted small">{name} isn't online on this server, so they can't be reached from the map.</p>
{/if}
<button class="primary" disabled={!canAct || !!busy} onclick={tpa}>
{#if busy === 'tpa'}<LoaderCircle class="spin" size={14} />{:else}<MapPin size={14} />{/if} Send teleport request
</button>
<div class="msg">
<input bind:value={message} placeholder="Message in game…" maxlength="200" disabled={!canAct} onkeydown={(e) => e.key === 'Enter' && send()} />
<button disabled={!canAct || !message.trim() || !!busy} onclick={send} aria-label="Send message">
{#if busy === 'message'}<LoaderCircle class="spin" size={14} />{:else}<Send size={14} />{/if}
</button>
</div>
<button disabled={!signedIn || isMe || !iCanInvite || !!busy} onclick={invite}
title={!myGuild ? 'You are not in a guild here' : !iCanInvite ? 'Only guild leaders and officers can invite' : ''}>
{#if busy === 'invite'}<LoaderCircle class="spin" size={14} />{:else}<UserPlus size={14} />{/if}
Invite to {myGuild ? `[${myGuild.tag}]` : 'my guild'}
</button>
</div>
{/if}
</aside>
<style>
.panel { position: absolute; top: 0.8rem; right: 0.8rem; bottom: 0.8rem; width: min(20rem, calc(100% - 1.6rem)); display: flex; flex-direction: column; gap: 0.9rem; padding: 1rem; overflow-y: auto; background: var(--surface); border: 1px solid var(--line-strong); border-radius: var(--radius); box-shadow: 0 1rem 3rem -1rem rgba(0, 0, 0, 0.7); animation: slide 0.18s ease; }
@keyframes slide { from { transform: translateX(1.5rem); opacity: 0; } }
.close { position: absolute; top: 0.5rem; right: 0.5rem; }
.center { flex: 1; display: grid; place-items: center; }
.pad { padding: 1rem; }
.head { display: flex; gap: 0.8rem; align-items: center; padding-right: 1.6rem; }
.ring { border: 3px solid var(--pa); border-radius: 28%; overflow: hidden; flex-shrink: 0; }
.who { display: flex; flex-direction: column; gap: 0.15rem; min-width: 0; }
.who h3 { margin: 0; font-size: 1.1rem; overflow: hidden; text-overflow: ellipsis; }
.title { display: inline-flex; align-items: center; gap: 0.3rem; color: #fbbf24; font-size: 0.78rem; }
.presence { display: inline-flex; align-items: center; gap: 0.4rem; font-size: 0.75rem; color: var(--muted); }
.presence i { width: 0.5rem; height: 0.5rem; border-radius: 50%; background: color-mix(in srgb, var(--muted) 60%, transparent); }
.presence i.on { background: #34d399; box-shadow: 0 0 0 3px color-mix(in srgb, #34d399 25%, transparent); }
.chips { display: flex; flex-wrap: wrap; gap: 0.35rem; }
.chip { display: inline-flex; align-items: center; gap: 0.3rem; font-size: 0.72rem; font-weight: 650; padding: 0.18rem 0.55rem; border-radius: 99rem; background: color-mix(in srgb, var(--text) 8%, transparent); }
.chip.lv { color: var(--pa); }
.chip.guild { color: #22d3ee; }
.chip.rank { color: #fbbf24; }
.facts { display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; }
.facts div { display: grid; grid-template-columns: auto 1fr; gap: 0.1rem 0.4rem; align-items: center; padding: 0.5rem 0.6rem; border-radius: var(--radius-sm); background: color-mix(in srgb, var(--text) 5%, transparent); font-size: 0.72rem; color: var(--muted); }
.facts strong { grid-column: 1 / -1; color: var(--text); font-size: 0.9rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bio { margin: 0; font-size: 0.82rem; color: var(--text-2, var(--text)); line-height: 1.4; }
.actions { display: flex; flex-direction: column; gap: 0.5rem; margin-top: auto; }
.actions button { display: inline-flex; align-items: center; justify-content: center; gap: 0.4rem; }
.msg { display: flex; gap: 0.4rem; }
.msg input { flex: 1; min-width: 0; }
.small { font-size: 0.75rem; margin: 0; }
</style>
+36
View File
@@ -69,7 +69,24 @@
} }
} }
type Invite = { id: number; guild_id: string; guild_name: string; guild_tag: string; icon_url: string; inviter: string };
let invites = $state<Invite[]>([]);
async function loadInvites() {
try { invites = await invoke<Invite[]>('my_guild_invites'); } catch { invites = []; }
}
async function answerInvite(inv: Invite, accept: boolean) {
try {
await invoke('respond_guild_invite', { inviteId: inv.id, accept });
toast(accept ? `Welcome to ${inv.guild_name}!` : 'Invitation declined', 'ok');
await Promise.all([loadInvites(), loadInstanceGuildData()]);
} catch (e: any) {
toast(typeof e === 'string' ? e : e?.message ?? 'Could not answer the invitation', 'error');
loadInvites();
}
}
onMount(async () => { onMount(async () => {
loadInvites();
try { walletServers = (await invoke<{ servers: typeof walletServers }>('get_public_servers')).servers; } try { walletServers = (await invoke<{ servers: typeof walletServers }>('get_public_servers')).servers; }
catch { walletServers = []; } catch { walletServers = []; }
}); });
@@ -169,6 +186,20 @@
</div> </div>
</div> </div>
{#if invites.length}
<section class="invites glass" aria-label="Guild invitations">
<h3>Guild invitations</h3>
{#each invites as inv (inv.id)}
<div class="invite">
<span class="dot"><Shield size={15} /></span>
<span class="text"><strong>[{inv.guild_tag}] {inv.guild_name}</strong><span class="muted tiny">Invited by {inv.inviter}</span></span>
<button class="sm ghost" onclick={() => answerInvite(inv, false)}>Decline</button>
<button class="sm primary" onclick={() => answerInvite(inv, true)} disabled={!!myGuild} title={myGuild ? 'Leave your guild first' : ''}>Accept</button>
</div>
{/each}
</section>
{/if}
{#if loading && !myGuild && !allGuilds.length} {#if loading && !myGuild && !allGuilds.length}
<div class="center-state"><LoaderCircle class="spin" size={32} /></div> <div class="center-state"><LoaderCircle class="spin" size={32} /></div>
{:else if myGuild} {:else if myGuild}
@@ -416,6 +447,11 @@
</Modal> </Modal>
<style> <style>
.invites { display: flex; flex-direction: column; gap: 0.6rem; padding: 0.9rem 1.1rem; border-radius: var(--radius); border: 1px solid color-mix(in srgb, var(--accent) 40%, var(--line)); }
.invites h3 { margin: 0; font-size: 0.95rem; }
.invite { display: flex; align-items: center; gap: 0.7rem; }
.invite .dot { width: 2rem; height: 2rem; display: grid; place-items: center; border-radius: 0.6rem; background: color-mix(in srgb, var(--accent) 18%, transparent); color: var(--accent); }
.invite .text { flex: 1; display: flex; flex-direction: column; min-width: 0; }
.guilds-page { .guilds-page {
height: 100%; height: 100%;
display: flex; display: flex;
+5 -4
View File
@@ -5,6 +5,7 @@
import NewsFeed from '../components/NewsFeed.svelte'; import NewsFeed from '../components/NewsFeed.svelte';
import Console from '../components/Console.svelte'; import Console from '../components/Console.svelte';
import LiveMapModal from '../components/LiveMapModal.svelte'; import LiveMapModal from '../components/LiveMapModal.svelte';
import BlueMapModal from '../components/BlueMapModal.svelte';
import { abs, activeAccount, app, instances, selectedInstance } from '../lib/store.svelte'; import { abs, activeAccount, app, instances, selectedInstance } from '../lib/store.svelte';
import { loaderName, shortLoaderVersion } from '../lib/format'; import { loaderName, shortLoaderVersion } from '../lib/format';
import { invoke, openUrl } from '../lib/tauri'; import { invoke, openUrl } from '../lib/tauri';
@@ -14,7 +15,7 @@
// Lucide ships no brand logos, so each platform gets a recognisable generic icon. // Lucide ships no brand logos, so each platform gets a recognisable generic icon.
const linkIcon: Record<string, any> = { discord: MessageCircle, website: Globe, store: ShoppingBag, youtube: CirclePlay, twitch: Tv, github: Code, twitter: AtSign }; const linkIcon: Record<string, any> = { discord: MessageCircle, website: Globe, store: ShoppingBag, youtube: CirclePlay, twitch: Tv, github: Code, twitter: AtSign };
// A game server linked to this instance with the panel's Live Map switched on. // A game server linked to this instance with the panel's Live Map switched on.
let mapServer = $state<{ id: number; name: string } | null>(null); let mapServer = $state<{ id: number; name: string; map_address?: string } | null>(null);
let mapOpen = $state(false); let mapOpen = $state(false);
$effect(() => { $effect(() => {
const id = inst?.id; const id = inst?.id;
@@ -22,8 +23,8 @@
mapOpen = false; mapOpen = false;
if (!id) return; if (!id) return;
let alive = true; let alive = true;
invoke<{ servers: Array<{ id: number; name: string; instance_id: string; live_map?: boolean }> }>('get_public_servers') invoke<{ servers: Array<{ id: number; name: string; instance_id: string; live_map?: boolean; map_address?: string }> }>('get_public_servers')
.then((r) => { if (alive) mapServer = r.servers.find((s) => s.instance_id === id && s.live_map) ?? null; }) .then((r) => { if (alive) mapServer = r.servers.find((s) => s.instance_id === id && (s.map_address || s.live_map)) ?? null; })
.catch(() => {}); .catch(() => {});
return () => { alive = false; }; return () => { alive = false; };
}); });
@@ -82,7 +83,7 @@
</aside> </aside>
{#if app.consoleOpen}<Console />{/if} {#if app.consoleOpen}<Console />{/if}
{#if mapOpen && mapServer}<LiveMapModal serverId={mapServer.id} serverName={mapServer.name} onclose={() => (mapOpen = false)} />{/if} {#if mapOpen && mapServer?.map_address}<BlueMapModal serverId={mapServer.id} serverName={mapServer.name} address={mapServer.map_address} instanceId={inst?.id ?? ''} onclose={() => (mapOpen = false)} />{:else if mapOpen && mapServer}<LiveMapModal serverId={mapServer.id} serverName={mapServer.name} onclose={() => (mapOpen = false)} />{/if}
</div> </div>
<style> <style>
@@ -18,6 +18,7 @@
<label class="field">Name<input bind:value={draft.name} maxlength="48" placeholder="Survival SMP" /></label> <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">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 <Toggle
bind:checked={draft.live_map_enabled} bind:checked={draft.live_map_enabled}
label="Live Map" label="Live Map"
+2 -1
View File
@@ -95,6 +95,7 @@ export interface GameServer {
instance_id: string; instance_id: string;
map_url: string; map_url: string;
live_map_enabled: boolean; live_map_enabled: boolean;
map_address: string;
economy_group: string; economy_group: string;
id: number; id: number;
name: string; name: string;
@@ -160,7 +161,7 @@ export interface LiveMapStatus {
roster: { uuid: string; name: string; dimension: string; x: number; y: number; z: number }[]; 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; access: 'all' | 'members' | 'groups'; allowed_groups: string[]; require_launcher: boolean }; 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 interface HostedDownload { export interface HostedDownload {
platform: 'windows' | 'mac' | 'linux'; platform: 'windows' | 'mac' | 'linux';
+1 -1
View File
@@ -80,7 +80,7 @@
function openEdit() { function openEdit() {
if (!s) return; 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, access: s.access, allowed_groups: [...s.allowed_groups], require_launcher: s.require_launcher }; 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 };
editOpen = true; editOpen = true;
} }
async function saveEdit() { async function saveEdit() {