Redesign guild treasury and territory toolbar; wallet API returns role and personal balance

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjMbLQujBHunCCu5GpsHaT
This commit is contained in:
Claude committed 2026-09-30 17:19:43 +00:00
1 parent fdc54577fd
commit f2362def88
12 files changed
+707 -166

No files matched your search

+80 -93
View File
@@ -2,10 +2,10 @@
import { onMount } from 'svelte';
import {
Shield, Lock, Plus, Trash2, Compass, ArrowUp, ArrowDown, ArrowLeft, ArrowRight,
MapPin, Globe, LoaderCircle, CheckCircle2, AlertTriangle, RefreshCw
MapPin, Globe, LoaderCircle, RefreshCw, Server
} from '@lucide/svelte';
import { invoke } from '../lib/tauri';
import { toast } from '../lib/store.svelte';
import { abs, toast } from '../lib/store.svelte';
import type { GuildClaim } from '../lib/types';
import VantageMap from './VantageMap.svelte';
@@ -23,7 +23,7 @@
onunclaim?: (claimId: number) => void;
} = $props();
let servers = $state<Array<{ id: number; name: string; instance_id: string; map_url: string }>>([]);
let servers = $state<Array<{ id: number; name: string; instance_id: string; map_url: string; live_map?: boolean }>>([]);
let serverId = $state<number | null>(null);
let dimension = $state<'minecraft:overworld' | 'minecraft:the_nether' | 'minecraft:the_end'>('minecraft:overworld');
let centerX = $state(0);
@@ -37,8 +37,26 @@
let selectedChunk = $state<{ x: number; z: number } | null>(null);
let mapDimensions = $state<string[]>([]);
let mapFailed = $state(false);
const mapUrl = $derived(servers.find((s) => s.id === serverId)?.map_url || '');
$effect(() => { mapUrl; mapFailed = false; mapDimensions = []; });
// The panel's central live map (if switched on for this server) wins over an external Vantage URL.
type LiveInfo = { ready: boolean; base: string; dimensions: { id: string; available: boolean }[] };
let liveInfo = $state<LiveInfo | null>(null);
const instanceServers = $derived(servers.filter((s) => s.instance_id === instanceId));
const selectedServer = $derived(servers.find((s) => s.id === serverId));
$effect(() => {
const id = selectedServer?.live_map ? selectedServer.id : null;
liveInfo = null;
if (!id) return;
let alive = true;
invoke<LiveInfo>('get_livemap_info', { serverId: id }).then((i) => { if (alive) liveInfo = i; }).catch(() => {});
return () => { alive = false; };
});
const central = $derived(!!liveInfo?.ready);
const dimSlug = $derived(dimension.replace('minecraft:', ''));
const mapUrl = $derived(central && liveInfo ? abs(`${liveInfo.base}/${dimSlug}/v1/worlds/default/manifest.json`) ?? '' : selectedServer?.map_url || '');
const dimDisabled = (id: string) => central
? !liveInfo?.dimensions.find((d) => d.id === id)?.available
: !!mapUrl && !mapFailed && mapDimensions.length > 0 && !mapDimensions.includes(id);
$effect(() => { mapUrl; mapFailed = false; if (!central) mapDimensions = []; });
const dimensions = [
{ id: 'minecraft:overworld', label: 'Overworld', icon: Globe },
@@ -144,45 +162,53 @@
</script>
<div class="chunk-map-container glass">
<label>Game server
<select bind:value={serverId}>
{#each servers.filter(s => s.instance_id === instanceId) as server}<option value={server.id}>{server.name}</option>{/each}
</select>
</label>
{#if !serverId}<p>Link a game server to this instance in the panel before claiming territory.</p>{/if}
<!-- Controls Bar -->
<div class="map-controls">
<div class="dim-selector">
{#each dimensions as dim}
{@const DimIcon = dim.icon}
<button
class="dim-btn"
class:active={dimension === dim.id}
disabled={!!mapUrl && !mapFailed && mapDimensions.length > 0 && !mapDimensions.includes(dim.id)}
onclick={() => { dimension = dim.id; claims = []; selectedChunk = null; }}
>
<DimIcon size={14} />
<span>{dim.label}</span>
</button>
{/each}
<div class="toolbar">
<div class="tb-group">
{#if instanceServers.length > 1}
<label class="server-pick" title="Game server"><Server size={14} />
<select bind:value={serverId} aria-label="Game server">
{#each instanceServers as server}<option value={server.id}>{server.name}</option>{/each}
</select>
</label>
{:else if instanceServers[0]}
<span class="chip server-chip"><Server size={13} /> {instanceServers[0].name}</span>
{/if}
<div class="dim-selector" role="tablist" aria-label="Dimension">
{#each dimensions as dim}
{@const DimIcon = dim.icon}
<button
role="tab"
aria-selected={dimension === dim.id}
class="dim-btn"
class:active={dimension === dim.id}
disabled={dimDisabled(dim.id)}
onclick={() => { dimension = dim.id; claims = []; selectedChunk = null; }}
>
<DimIcon size={14} />
<span>{dim.label}</span>
</button>
{/each}
</div>
{#if central}<span class="chip live-chip"><span class="pulse"></span> Live map</span>{/if}
</div>
<div class="nav-controls">
<span class="coord-label">Center: [{centerX}, {centerZ}]</span>
<div class="nav-buttons">
<button class="ghost icon sm" onclick={() => pan(0, -2)} title="Pan North"><ArrowUp size={14} /></button>
<div class="nav-row">
<button class="ghost icon sm" onclick={() => pan(-2, 0)} title="Pan West"><ArrowLeft size={14} /></button>
<button class="ghost icon sm" onclick={resetCenter} title="Center at (0,0)"><MapPin size={14} /></button>
<button class="ghost icon sm" onclick={() => pan(2, 0)} title="Pan East"><ArrowRight size={14} /></button>
</div>
<button class="ghost icon sm" onclick={() => pan(0, 2)} title="Pan South"><ArrowDown size={14} /></button>
<div class="tb-group">
<span class="coord-label" title="Chunk at the centre of the view">Chunk {centerX}, {centerZ}</span>
<div class="pad">
<button class="ghost icon sm" onclick={() => pan(-2, 0)} title="Pan west" aria-label="Pan west"><ArrowLeft size={14} /></button>
<button class="ghost icon sm" onclick={() => pan(0, -2)} title="Pan north" aria-label="Pan north"><ArrowUp size={14} /></button>
<button class="ghost icon sm" onclick={resetCenter} title="Back to the world origin" aria-label="Centre on origin"><MapPin size={14} /></button>
<button class="ghost icon sm" onclick={() => pan(0, 2)} title="Pan south" aria-label="Pan south"><ArrowDown size={14} /></button>
<button class="ghost icon sm" onclick={() => pan(2, 0)} title="Pan east" aria-label="Pan east"><ArrowRight size={14} /></button>
</div>
<button class="ghost icon sm refresh-btn" onclick={loadClaims} title="Refresh Claims">
<button class="ghost icon sm" onclick={loadClaims} title="Refresh claims" aria-label="Refresh claims">
<RefreshCw size={14} class={loading ? 'spin' : ''} />
</button>
</div>
</div>
{#if !serverId}
<div class="notice"><Server size={16} /> Link a game server to this instance in the panel before claiming territory.</div>
{/if}
<!-- Main Chunk Map Canvas / Grid -->
{#if mapUrl && !mapFailed}
@@ -190,6 +216,7 @@
<VantageMap
world={mapUrl}
serverId={serverId!}
{central}
{claims}
{dimension}
{myGuildId}
@@ -316,62 +343,22 @@
border: 1px solid var(--line);
user-select: none;
}
.map-controls {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 0.6rem;
}
.dim-selector {
display: flex;
background: color-mix(in srgb, var(--bg) 60%, transparent);
padding: 0.2rem;
border-radius: var(--radius-sm);
border: 1px solid var(--line);
gap: 0.2rem;
}
.dim-btn {
display: flex;
align-items: center;
gap: 0.35rem;
background: transparent;
border: none;
padding: 0.35rem 0.75rem;
font-size: 0.8rem;
font-weight: 560;
color: var(--muted);
border-radius: calc(var(--radius-sm) - 2px);
transition: all 0.15s;
}
.dim-btn.active {
background: color-mix(in srgb, var(--accent) 15%, transparent);
color: var(--text);
}
.nav-controls {
display: flex;
align-items: center;
gap: 0.6rem;
}
.coord-label {
font-size: 0.8rem;
font-weight: 600;
color: var(--muted);
font-variant-numeric: tabular-nums;
}
.nav-buttons {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.1rem;
}
.nav-row {
display: flex;
gap: 0.1rem;
}
.refresh-btn {
margin-left: 0.3rem;
}
.toolbar { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 0.6rem 1rem; }
.tb-group { display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; }
.server-pick { position: relative; display: inline-flex; align-items: center; }
.server-pick :global(svg) { position: absolute; left: 0.7rem; color: var(--muted); pointer-events: none; }
.server-pick select { width: auto; min-width: 10rem; padding: 0.4rem 2rem 0.4rem 2rem; font-size: 0.82rem; font-weight: 560; border-radius: 99rem; }
.server-chip { padding: 0.4rem 0.8rem; border-radius: 99rem; font-size: 0.82rem; }
.live-chip { border-radius: 99rem; color: var(--success); background: color-mix(in srgb, var(--success) 14%, transparent); }
.pulse { width: 0.45rem; height: 0.45rem; border-radius: 50%; background: var(--success); box-shadow: 0 0 0 0 color-mix(in srgb, var(--success) 60%, transparent); animation: pulse 2s infinite; }
@keyframes pulse { 70% { box-shadow: 0 0 0 0.4rem transparent; } 100% { box-shadow: 0 0 0 0 transparent; } }
.dim-selector { display: flex; background: color-mix(in srgb, var(--bg) 60%, transparent); padding: 0.2rem; border-radius: 99rem; border: 1px solid var(--line); gap: 0.15rem; }
.dim-btn { display: flex; align-items: center; gap: 0.35rem; background: transparent; border: none; padding: 0.3rem 0.8rem; font-size: 0.8rem; font-weight: 560; color: var(--muted); border-radius: 99rem; transition: all 0.15s; }
.dim-btn:hover:not(:disabled) { color: var(--text); }
.dim-btn.active { background: color-mix(in srgb, var(--accent) 22%, transparent); color: var(--text); }
.coord-label { font-size: 0.78rem; font-weight: 600; color: var(--muted); font-variant-numeric: tabular-nums; }
.pad { display: inline-flex; padding: 0.15rem; border-radius: var(--radius-sm); border: 1px solid var(--line); background: color-mix(in srgb, var(--bg) 60%, transparent); }
.notice { display: flex; align-items: center; gap: 0.6rem; padding: 0.7rem 0.9rem; border-radius: var(--radius-sm); font-size: 0.85rem; color: var(--warn); background: color-mix(in srgb, var(--warn) 10%, transparent); border: 1px solid color-mix(in srgb, var(--warn) 30%, transparent); }
.grid-wrapper {
overflow: auto;
display: flex;
+287
View File
@@ -0,0 +1,287 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
Landmark, ArrowDownToLine, ArrowUpFromLine, Wallet, Lock, Server, History, Coins, LoaderCircle, RefreshCw, TrendingUp
} from '@lucide/svelte';
import Avatar from './Avatar.svelte';
import { invoke } from '../lib/tauri';
import { toast } from '../lib/store.svelte';
import type { Guild, GuildMember } from '../lib/types';
type Tx = { id: number; actor_uuid: string; kind: 'deposit' | 'withdraw'; amount: number; created_at: string };
type WalletData = { balance: number; my_balance?: number; role?: string; currency_symbol?: string; transactions: Tx[] };
let { guild, members, servers, instanceId }: {
guild: Guild;
members: GuildMember[];
servers: Array<{ id: number; name: string; instance_id: string }>;
instanceId: string;
} = $props();
let serverId = $state<number | null>(null);
let wallet = $state<WalletData | null>(null);
let loading = $state(false);
let busy = $state(false);
let mode = $state<'deposit' | 'withdraw'>('deposit');
let amount = $state('');
let shown = $state(0);
const instanceServers = $derived(servers.filter((s) => s.instance_id === instanceId));
const symbol = $derived(wallet?.currency_symbol ?? '$');
const role = $derived(wallet?.role ?? 'member');
const canWithdraw = $derived(role === 'leader' || role === 'officer');
const value = $derived(Number(amount));
const validAmount = $derived(Number.isFinite(value) && value >= 0.01 && Math.abs(value * 100 - Math.round(value * 100)) < 1e-6);
const limit = $derived(mode === 'deposit' ? wallet?.my_balance : wallet?.balance);
const overLimit = $derived(validAmount && limit != null && value > limit + 1e-9);
const afterGuild = $derived(wallet && validAmount ? wallet.balance + (mode === 'deposit' ? value : -value) : null);
const afterMine = $derived(wallet?.my_balance != null && validAmount ? wallet.my_balance + (mode === 'deposit' ? -value : value) : null);
const totals = $derived({
in: wallet?.transactions.filter((t) => t.kind === 'deposit').reduce((n, t) => n + t.amount, 0) ?? 0,
out: wallet?.transactions.filter((t) => t.kind === 'withdraw').reduce((n, t) => n + t.amount, 0) ?? 0
});
const quick = [10, 50, 100, 500, 1000];
const money = (n: number) => `${symbol}${n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
const who = (uuid: string) => members.find((m) => m.uuid === uuid);
function ago(iso: string): string {
const s = (Date.now() - new Date(iso).getTime()) / 1000;
if (s < 60) return 'just now';
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
if (s < 86400 * 7) return `${Math.floor(s / 86400)}d ago`;
return new Date(iso).toLocaleDateString();
}
// Count the balance up (or down) smoothly whenever it changes.
let frame = 0;
$effect(() => {
const target = wallet?.balance ?? 0;
const from = shown;
const start = performance.now();
cancelAnimationFrame(frame);
const step = (now: number) => {
const t = Math.min(1, (now - start) / 550);
shown = from + (target - from) * (1 - Math.pow(1 - t, 3));
if (t < 1) frame = requestAnimationFrame(step);
};
frame = requestAnimationFrame(step);
return () => cancelAnimationFrame(frame);
});
async function load() {
if (!serverId) { wallet = null; return; }
loading = true;
try {
wallet = await invoke<WalletData>('get_guild_wallet', { guildId: guild.id, serverId });
if (!canWithdraw) mode = 'deposit';
} catch (e: any) {
toast(typeof e === 'string' ? e : e?.message ?? 'Unable to load the guild wallet', 'error');
} finally {
loading = false;
}
}
async function submit() {
if (!serverId || busy || !validAmount || overLimit) return;
busy = true;
try {
await invoke('transfer_guild_wallet', { guildId: guild.id, serverId, amount: value, withdraw: mode === 'withdraw' });
toast(mode === 'withdraw' ? `Withdrew ${money(value)} from the treasury` : `Deposited ${money(value)} into the treasury`, 'ok');
amount = '';
await load();
} catch (e: any) {
toast(typeof e === 'string' ? e : e?.message ?? 'Transfer failed', 'error');
} finally {
busy = false;
}
}
$effect(() => {
if (!instanceServers.some((s) => s.id === serverId)) serverId = instanceServers[0]?.id ?? null;
});
$effect(() => { void serverId; void guild.id; load(); });
onMount(() => () => cancelAnimationFrame(frame));
</script>
<div class="wallet">
{#if instanceServers.length === 0}
<div class="empty glass">
<span class="empty-icon"><Server size={26} /></span>
<h3>No game server linked</h3>
<p class="muted">The guild treasury lives on a game server's economy. Ask an admin to link a server to this instance in the panel.</p>
</div>
{:else}
{#if instanceServers.length > 1}
<div class="servers" role="tablist" aria-label="Game server">
{#each instanceServers as s (s.id)}
<button role="tab" aria-selected={serverId === s.id} class="srv" class:on={serverId === s.id} onclick={() => (serverId = s.id)}>
<Server size={13} /> {s.name}
</button>
{/each}
</div>
{/if}
{#if !wallet && loading}
<div class="empty glass"><LoaderCircle class="spin" size={28} /></div>
{:else if wallet}
<div class="cols">
<div class="left">
<section class="treasury">
<div class="glow" aria-hidden="true"></div>
<div class="t-top">
<span class="t-label"><Landmark size={14} /> Guild treasury</span>
<span class="tag">[{guild.tag}]</span>
<button class="ghost icon sm refresh" onclick={load} aria-label="Refresh wallet" title="Refresh"><RefreshCw size={14} class={loading ? 'spin' : ''} /></button>
</div>
<div class="amount">{money(shown)}</div>
<div class="t-stats">
<div><span class="k"><TrendingUp size={12} /> Deposited</span><strong class="in">{money(totals.in)}</strong></div>
<div><span class="k"><ArrowUpFromLine size={12} /> Withdrawn</span><strong class="out">{money(totals.out)}</strong></div>
<div><span class="k"><Wallet size={12} /> Your wallet</span><strong>{wallet.my_balance != null ? money(wallet.my_balance) : '—'}</strong></div>
</div>
<p class="t-note tiny">Totals cover the last {wallet.transactions.length} transfers.</p>
</section>
<section class="transfer glass">
<div class="mode" role="tablist">
<button role="tab" aria-selected={mode === 'deposit'} class:on={mode === 'deposit'} onclick={() => (mode = 'deposit')}>
<ArrowDownToLine size={15} /> Deposit
</button>
<button
role="tab"
aria-selected={mode === 'withdraw'}
class:on={mode === 'withdraw'}
disabled={!canWithdraw}
title={canWithdraw ? '' : 'Only guild leaders and officers can withdraw'}
onclick={() => (mode = 'withdraw')}
>
{#if canWithdraw}<ArrowUpFromLine size={15} />{:else}<Lock size={14} />{/if} Withdraw
</button>
</div>
<label class="field">
<span>Amount</span>
<div class="money" class:bad={overLimit}>
<span class="sym">{symbol}</span>
<input type="number" inputmode="decimal" min="0.01" step="0.01" placeholder="0.00" bind:value={amount} onkeydown={(e) => e.key === 'Enter' && submit()} />
{#if limit != null}<button class="max" type="button" onclick={() => (amount = String(Math.floor(limit * 100) / 100))}>Max</button>{/if}
</div>
</label>
<div class="quick">
{#each quick as q}<button type="button" class="q" onclick={() => (amount = String(q))}>{symbol}{q.toLocaleString()}</button>{/each}
</div>
{#if afterGuild != null && !overLimit}
<div class="preview">
<span>Treasury <strong>{money(wallet.balance)}</strong> → <strong class="hl">{money(afterGuild)}</strong></span>
{#if afterMine != null}<span>You <strong>{money(wallet.my_balance!)}</strong> → <strong class="hl">{money(afterMine)}</strong></span>{/if}
</div>
{:else if overLimit}
<p class="warn tiny">{mode === 'deposit' ? "That's more than your wallet holds." : "That's more than the treasury holds."}</p>
{:else}
<p class="muted tiny">Use up to two decimals. Transfers are instant and recorded for the whole guild.</p>
{/if}
<button class="primary go" disabled={busy || !validAmount || overLimit} onclick={submit}>
{#if busy}<LoaderCircle class="spin" size={15} />{:else if mode === 'deposit'}<ArrowDownToLine size={15} />{:else}<ArrowUpFromLine size={15} />{/if}
{mode === 'deposit' ? 'Deposit to treasury' : 'Withdraw to wallet'}
</button>
</section>
</div>
<section class="history glass">
<header><h3><History size={16} /> Recent activity</h3><span class="chip">{wallet.transactions.length}</span></header>
{#if wallet.transactions.length === 0}
<div class="none">
<Coins size={28} />
<p>No transfers yet.</p>
<p class="tiny muted">Be the first to fund the guild treasury.</p>
</div>
{:else}
<ul>
{#each wallet.transactions as tx (tx.id)}
{@const m = who(tx.actor_uuid)}
<li>
<span class="ic" class:out={tx.kind === 'withdraw'}>
{#if tx.kind === 'withdraw'}<ArrowUpFromLine size={14} />{:else}<ArrowDownToLine size={14} />{/if}
</span>
<Avatar uuid={tx.actor_uuid} name={m?.name ?? '?'} size={1.7} />
<div class="who">
<strong>{m?.name ?? 'Former member'}</strong>
<span class="tiny muted">{tx.kind === 'withdraw' ? 'withdrew' : 'deposited'} · {ago(tx.created_at)}</span>
</div>
<span class="amt" class:out={tx.kind === 'withdraw'}>{tx.kind === 'withdraw' ? '−' : '+'}{money(tx.amount)}</span>
</li>
{/each}
</ul>
{/if}
</section>
</div>
{/if}
{/if}
</div>
<style>
.wallet { display: flex; flex-direction: column; gap: 1rem; animation: fade-up 0.25s var(--ease) both; }
.servers { display: flex; gap: 0.4rem; flex-wrap: wrap; }
.srv { padding: 0.4rem 0.85rem; font-size: 0.82rem; border-radius: 99rem; color: var(--muted); background: transparent; }
.srv.on { color: var(--text); background: color-mix(in srgb, var(--accent) 18%, transparent); border-color: color-mix(in srgb, var(--accent) 45%, transparent); }
.cols { display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr); gap: 1rem; align-items: start; }
@media (max-width: 980px) { .cols { grid-template-columns: 1fr; } }
.left { display: flex; flex-direction: column; gap: 1rem; }
.treasury { position: relative; overflow: hidden; padding: 1.4rem 1.6rem 1.1rem; border-radius: var(--radius); border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--line)); background: linear-gradient(140deg, color-mix(in srgb, var(--accent) 26%, var(--surface)), var(--surface) 70%); }
.glow { position: absolute; width: 18rem; height: 18rem; right: -6rem; top: -9rem; border-radius: 50%; background: radial-gradient(circle, color-mix(in srgb, var(--accent-2) 45%, transparent), transparent 70%); pointer-events: none; }
.t-top { position: relative; display: flex; align-items: center; gap: 0.6rem; }
.t-label { display: inline-flex; align-items: center; gap: 0.4rem; font-size: 0.8rem; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; color: color-mix(in srgb, var(--text) 75%, transparent); }
.tag { font-weight: 800; letter-spacing: 0.08em; font-size: 0.78rem; color: var(--accent-2); }
.refresh { margin-left: auto; }
.amount { position: relative; margin: 0.7rem 0 1rem; font-size: clamp(2.2rem, 5vw, 3.1rem); font-weight: 750; letter-spacing: -0.03em; font-variant-numeric: tabular-nums; }
.t-stats { position: relative; display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.75rem; padding-top: 0.9rem; border-top: 1px solid var(--line); }
.t-stats > div { display: flex; flex-direction: column; gap: 0.2rem; min-width: 0; }
.k { display: inline-flex; align-items: center; gap: 0.3rem; font-size: 0.72rem; color: var(--muted); }
.t-stats strong { font-size: 0.95rem; font-weight: 650; font-variant-numeric: tabular-nums; overflow: hidden; text-overflow: ellipsis; }
.in { color: var(--success); }
.out { color: color-mix(in srgb, var(--danger) 75%, white); }
.t-note { position: relative; margin-top: 0.7rem; color: var(--muted); }
.transfer { display: flex; flex-direction: column; gap: 0.85rem; padding: 1.1rem 1.25rem 1.25rem; background: var(--surface); }
.mode { display: grid; grid-template-columns: 1fr 1fr; gap: 0.25rem; padding: 0.25rem; border-radius: var(--radius-sm); background: color-mix(in srgb, var(--bg) 70%, transparent); border: 1px solid var(--line); }
.mode button { border: 0; background: transparent; color: var(--muted); padding: 0.5rem; }
.mode button.on { background: color-mix(in srgb, var(--accent) 22%, transparent); color: var(--text); }
.field { display: flex; flex-direction: column; gap: 0.4rem; font-size: 0.82rem; font-weight: 560; }
.money { display: flex; align-items: center; gap: 0.5rem; padding: 0 0.5rem 0 0.9rem; border-radius: var(--radius-sm); border: 1px solid var(--line-strong); background: color-mix(in srgb, var(--bg) 70%, transparent); transition: border-color 0.15s; }
.money:focus-within { border-color: var(--accent); }
.money.bad { border-color: var(--danger); }
.sym { color: var(--muted); font-weight: 650; }
.money input { border: 0; background: transparent; padding: 0.75rem 0; font-size: 1.15rem; font-weight: 650; font-variant-numeric: tabular-nums; }
.money input:focus { background: transparent; }
.max { padding: 0.25rem 0.6rem; font-size: 0.74rem; border-radius: 99rem; }
.quick { display: flex; gap: 0.4rem; flex-wrap: wrap; }
.q { padding: 0.3rem 0.7rem; font-size: 0.78rem; border-radius: 99rem; color: var(--muted); font-variant-numeric: tabular-nums; }
.q:hover { color: var(--text); }
.preview { display: flex; justify-content: space-between; gap: 0.75rem; flex-wrap: wrap; padding: 0.6rem 0.8rem; border-radius: var(--radius-sm); font-size: 0.8rem; color: var(--muted); background: color-mix(in srgb, var(--text) 4%, transparent); }
.preview strong { color: var(--text); font-variant-numeric: tabular-nums; }
.preview .hl { color: var(--accent-2); }
.warn { color: var(--warn); }
.go { padding: 0.75rem; font-size: 0.95rem; }
.history { padding: 1.1rem 0.4rem 0.6rem; background: var(--surface); min-height: 18rem; }
.history header { display: flex; align-items: center; justify-content: space-between; padding: 0 1rem 0.7rem; }
.history h3 { display: inline-flex; align-items: center; gap: 0.5rem; font-size: 0.98rem; }
ul { list-style: none; margin: 0; padding: 0 0.4rem; display: flex; flex-direction: column; max-height: 30rem; overflow-y: auto; }
li { display: grid; grid-template-columns: auto auto 1fr auto; align-items: center; gap: 0.7rem; padding: 0.6rem 0.6rem; border-radius: var(--radius-sm); }
li:hover { background: color-mix(in srgb, var(--text) 4%, transparent); }
.ic { width: 1.7rem; height: 1.7rem; display: grid; place-items: center; border-radius: 50%; background: color-mix(in srgb, var(--success) 16%, transparent); color: var(--success); }
.ic.out { background: color-mix(in srgb, var(--danger) 16%, transparent); color: color-mix(in srgb, var(--danger) 75%, white); }
.who { display: flex; flex-direction: column; min-width: 0; }
.who strong { font-size: 0.86rem; font-weight: 620; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.amt { font-weight: 680; font-size: 0.9rem; font-variant-numeric: tabular-nums; color: var(--success); }
.amt.out { color: color-mix(in srgb, var(--danger) 75%, white); }
.none { display: flex; flex-direction: column; align-items: center; gap: 0.3rem; padding: 2.5rem 1rem; color: var(--muted); text-align: center; }
.empty { display: flex; flex-direction: column; align-items: center; gap: 0.6rem; padding: 3rem 1.5rem; text-align: center; background: var(--surface); }
.empty p { max-width: 28rem; }
.empty-icon { width: 3.4rem; height: 3.4rem; border-radius: var(--radius); display: grid; place-items: center; background: color-mix(in srgb, var(--accent) 15%, transparent); color: var(--accent-2); }
</style>
+131
View File
@@ -0,0 +1,131 @@
<script lang="ts">
import { onMount } from 'svelte';
import { X, Globe, Flame, Sparkles, Users, LoaderCircle, RefreshCw, MapPinOff } from '@lucide/svelte';
import { invoke } from '../lib/tauri';
import { abs } from '../lib/store.svelte';
import VantageMap from './VantageMap.svelte';
let { serverId, serverName, onclose }: { serverId: number; serverName: string; onclose: () => void } = $props();
type Info = {
enabled: boolean;
ready: boolean;
message: string;
base: string;
players: number;
dimensions: { id: string; slug: string; label: string; available: boolean }[];
};
let info = $state<Info | null>(null);
let slug = $state('overworld');
let error = $state('');
let failed = $state('');
let loaded = $state(false);
const icons: Record<string, any> = { overworld: Globe, the_nether: Flame, the_end: Sparkles };
const url = $derived(info?.ready ? abs(`${info.base}/${slug}/v1/worlds/default/manifest.json`) : null);
async function refresh() {
try {
info = await invoke<Info>('get_livemap_info', { serverId });
error = '';
if (!info.dimensions.find((d) => d.slug === slug)?.available) slug = info.dimensions.find((d) => d.available)?.slug ?? 'overworld';
} catch (e: any) {
error = typeof e === 'string' ? e : e?.message ?? 'Unable to reach the panel';
}
}
onMount(() => {
refresh();
// While the map is still being prepared, keep checking; afterwards only the roster count changes.
const t = setInterval(refresh, 15_000);
return () => clearInterval(t);
});
</script>
<svelte:window onkeydown={(e) => e.key === 'Escape' && onclose()} />
<div class="overlay" role="dialog" aria-modal="true" aria-label="Live map">
<header class="glass">
<div class="title">
<span class="live" class:on={info?.ready}></span>
<div>
<strong>{serverName}</strong>
<span class="muted">Live Map{info?.ready ? ` · ${info.players} ${info.players === 1 ? 'player' : 'players'} online` : ''}</span>
</div>
</div>
{#if info?.ready}
<nav class="dims">
{#each info.dimensions as d (d.slug)}
{@const Icon = icons[d.slug] ?? Globe}
<button class="dim" class:on={slug === d.slug} disabled={!d.available} onclick={() => { slug = d.slug; failed = ''; loaded = false; }}>
<Icon size={14} /> {d.label}
</button>
{/each}
</nav>
{/if}
{#if info?.ready}<span class="chip players"><Users size={13} /> {info.players}</span>{/if}
<button class="ghost icon close" onclick={onclose} aria-label="Close live map"><X size={18} /></button>
</header>
<div class="stage">
{#if url && !failed}
{#key `${serverId}:${slug}`}
<VantageMap
world={url}
{serverId}
central
fill
onready={() => (loaded = true)}
onerror={(m) => (failed = m)}
/>
{/key}
{#if !loaded}<div class="note"><LoaderCircle class="spin" size={22} /> Rendering terrain…</div>{/if}
{:else}
<div class="note card">
{#if failed}
<MapPinOff size={30} />
<h3>Couldn't load the map</h3>
<p class="muted selectable">{failed}</p>
<button class="primary" onclick={() => { failed = ''; loaded = false; refresh(); }}><RefreshCw size={15} /> Try again</button>
{:else if error}
<MapPinOff size={30} />
<h3>Live Map unavailable</h3>
<p class="muted selectable">{error}</p>
<button class="primary" onclick={refresh}><RefreshCw size={15} /> Try again</button>
{:else if info}
<LoaderCircle class="spin" size={30} />
<h3>{info.enabled ? 'The map is being prepared' : 'Live Map is off'}</h3>
<p class="muted">{info.message || 'Hang on…'}</p>
<p class="tiny muted">This page checks again automatically.</p>
{:else}
<LoaderCircle class="spin" size={28} />
{/if}
</div>
{/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: 1rem; padding: 0.65rem 0.8rem 0.65rem 1rem; background: var(--surface); flex-shrink: 0; }
.title { display: flex; align-items: center; gap: 0.75rem; min-width: 0; margin-right: auto; }
.title div { display: flex; flex-direction: column; line-height: 1.25; min-width: 0; }
.title strong { font-size: 0.98rem; font-weight: 620; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.title .muted { font-size: 0.78rem; }
.live { width: 0.6rem; height: 0.6rem; border-radius: 50%; background: color-mix(in srgb, var(--muted) 50%, transparent); flex-shrink: 0; }
.live.on { background: var(--success); box-shadow: 0 0 0 0.25rem color-mix(in srgb, var(--success) 22%, transparent); }
.dims { display: flex; gap: 0.3rem; padding: 0.2rem; border-radius: 99px; background: color-mix(in srgb, var(--text) 6%, transparent); }
.dim { display: inline-flex; align-items: center; gap: 0.4rem; padding: 0.4rem 0.85rem; border-radius: 99px; font-size: 0.82rem; border: 0; background: transparent; color: var(--muted); }
.dim:hover:not(:disabled) { color: var(--text); }
.dim.on { background: var(--accent); color: #fff; }
.dim:disabled { opacity: 0.35; }
.players { display: inline-flex; align-items: center; gap: 0.35rem; }
.close { flex-shrink: 0; }
.stage { position: relative; flex: 1; min-height: 0; border-radius: var(--radius); overflow: hidden; border: 1px solid var(--line); background: #102026; }
.stage :global(.vantage-map) { position: absolute; inset: 0; }
.note { position: absolute; inset: 0; margin: auto; width: fit-content; height: fit-content; max-width: 26rem; display: flex; flex-direction: column; align-items: center; gap: 0.6rem; padding: 1.6rem 2rem; text-align: center; color: #e5e7eb; }
.stage > .note:not(.card) { inset: auto 1rem 1rem auto; margin: 0; flex-direction: row; padding: 0.55rem 0.9rem; border-radius: 99px; background: rgba(9, 18, 22, 0.8); font-size: 0.85rem; }
.card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); color: var(--text); }
.card h3 { font-size: 1.05rem; }
</style>
+25 -14
View File
@@ -5,16 +5,21 @@
import type { GuildClaim } from '../lib/types';
import { invoke, inTauri } from '../lib/tauri';
let { world, serverId, claims, dimension, myGuildId, onselect, ondimension, ondimensions, oncenterchange, onerror }: {
let { world, serverId, claims = [], dimension = 'minecraft:overworld', myGuildId = null, central = false, fill = false, onselect = () => {}, ondimension = () => {}, ondimensions = () => {}, oncenterchange = () => {}, onready = () => {}, onerror }: {
world: string;
serverId: number;
claims: GuildClaim[];
dimension: string;
myGuildId: string | null;
onselect: (x: number, z: number) => void;
ondimension: (dimension: 'minecraft:overworld' | 'minecraft:the_nether' | 'minecraft:the_end') => void;
ondimensions: (dimensions: string[]) => void;
oncenterchange: (x: number, z: number) => void;
claims?: GuildClaim[];
dimension?: string;
myGuildId?: string | null;
/** Served by the panel's central live map (session-authenticated) rather than an external Vantage host. */
central?: boolean;
/** Fill the parent's height instead of using the compact territory size. */
fill?: boolean;
onselect?: (x: number, z: number) => void;
ondimension?: (dimension: 'minecraft:overworld' | 'minecraft:the_nether' | 'minecraft:the_end') => void;
ondimensions?: (dimensions: string[]) => void;
oncenterchange?: (x: number, z: number) => void;
onready?: () => void;
onerror: (message: string) => void;
} = $props();
@@ -117,7 +122,7 @@
const source = inTauri ? core.worldFromHttp(world, { fetch: async (url, init) => {
if (init?.signal?.aborted) throw new DOMException('Aborted', 'AbortError');
const headers = new Headers(init?.headers);
const result = await invoke<{ status: number; data: string | null; etag: string | null }>('fetch_vantage_resource', {
const result = await invoke<{ status: number; data: string | null; etag: string | null }>(central ? 'fetch_livemap_resource' : 'fetch_vantage_resource', {
serverId, url, etag: headers.get('if-none-match')
});
if (init?.signal?.aborted) throw new DOMException('Aborted', 'AbortError');
@@ -139,13 +144,18 @@
if (cancelled) { v.dispose(); return; }
viewer = v;
ready = true;
ondimensions(v.dimensions.map((d) => d.id).length ? v.dimensions.map((d) => d.id) : [v.dimension?.id || 'minecraft:overworld']);
const initialId = v.dimension?.id;
if (initialId === 'minecraft:overworld' || initialId === 'minecraft:the_nether' || initialId === 'minecraft:the_end') ondimension(initialId);
onready();
// A central map serves one manifest per dimension, so the dimension is chosen by URL, not by the viewer.
if (!central) {
ondimensions(v.dimensions.map((d) => d.id).length ? v.dimensions.map((d) => d.id) : [v.dimension?.id || 'minecraft:overworld']);
const initialId = v.dimension?.id;
if (initialId === 'minecraft:overworld' || initialId === 'minecraft:the_nether' || initialId === 'minecraft:the_end') ondimension(initialId);
}
const canvas = v.renderer.domElement;
canvas.addEventListener('pointerdown', downHandler);
canvas.addEventListener('pointerup', upHandler);
v.on('dimension', ({ current }) => {
if (central) return;
ondimensions(v.dimensions.map((d) => d.id).length ? v.dimensions.map((d) => d.id) : [current?.id || 'minecraft:overworld']);
const id = current?.id;
if (id === 'minecraft:overworld' || id === 'minecraft:the_nether' || id === 'minecraft:the_end') ondimension(id);
@@ -178,19 +188,20 @@
});
$effect(() => {
if (viewer && ready && viewer.dimension && viewer.dimension.id !== dimension && viewer.dimensions.some((d) => d.id === dimension)) {
if (!central && viewer && ready && viewer.dimension && viewer.dimension.id !== dimension && viewer.dimensions.some((d) => d.id === dimension)) {
void viewer.setDimension(dimension).catch((e) => { error = String(e); });
}
});
</script>
<div class="vantage-map" bind:this={host}>
<div class="vantage-map" class:fill bind:this={host}>
{#if !ready && !error}<div class="map-message">Loading live terrain map…</div>{/if}
{#if error}<div class="map-message error">Map unavailable: {error}</div>{/if}
</div>
<style>
.vantage-map { height: min(60vh, 680px); min-height: 430px; position: relative; overflow: hidden; border-radius: 14px; background: #102026; }
.vantage-map.fill { height: 100%; min-height: 0; border-radius: 0; }
.vantage-map :global(canvas) { display: block; }
.map-message { position: absolute; z-index: 1; inset: 0; display: grid; place-items: center; padding: 24px; color: #e5e7eb; text-align: center; pointer-events: none; }
.error { background: #102026; color: #fda4af; }
+25 -1
View File
@@ -121,11 +121,26 @@ async function fakeLaunch(id: string) {
}
}
const wallet = {
balance: 12840.5, my_balance: 2450.75, role: 'leader', currency_symbol: '$',
transactions: [
{ id: 5, actor_uuid: 'mock-user-1', kind: 'deposit', amount: 500, created_at: new Date(Date.now() - 12 * 60_000).toISOString() },
{ id: 4, actor_uuid: 'friend-1', kind: 'deposit', amount: 1250.25, created_at: new Date(Date.now() - 3 * 3600_000).toISOString() },
{ id: 3, actor_uuid: 'mock-user-1', kind: 'withdraw', amount: 300, created_at: new Date(Date.now() - 26 * 3600_000).toISOString() },
{ id: 2, actor_uuid: 'friend-2', kind: 'deposit', amount: 75, created_at: new Date(Date.now() - 3 * 86400_000).toISOString() },
{ id: 1, actor_uuid: 'gone-player', kind: 'deposit', amount: 10000, created_at: new Date(Date.now() - 9 * 86400_000).toISOString() }
] as Array<{ id: number; actor_uuid: string; kind: 'deposit' | 'withdraw'; amount: number; created_at: string }>
};
const mockWallet = () => JSON.parse(JSON.stringify(wallet));
export async function mockInvoke(cmd: string, args: Record<string, any>): Promise<unknown> {
await sleep(40);
switch (cmd) {
case 'get_public_servers':
return { servers: [{ id: 1, name: 'Survival SMP', instance_id: 'survival-smp', map_url: '', online: true, players_online: 42, players_max: 100 }] };
return { servers: [{ id: 1, name: 'Survival SMP', instance_id: 'survival-smp', map_url: '', live_map: true, online: true, players_online: 42, players_max: 100 }] };
case 'get_livemap_info':
return { enabled: true, ready: false, message: 'The map generator isn\'t set up on the panel yet.', base: '/api/livemap/1', players: 0,
dimensions: [{ id: 'minecraft:overworld', slug: 'overworld', label: 'Overworld', available: true }, { id: 'minecraft:the_nether', slug: 'the_nether', label: 'The Nether', available: false }, { id: 'minecraft:the_end', slug: 'the_end', label: 'The End', available: false }] };
case 'get_player_stats':
return { username: 'Notch', uuid: 'b50ad385-829d-3141-a216-7e7d7539ba7f', total: null, servers: [], events: [] };
case 'get_leaderboard':
@@ -225,6 +240,15 @@ export async function mockInvoke(cmd: string, args: Record<string, any>): Promis
];
case 'get_my_guild':
return { id: 'g1', instance_id: args?.instanceId ?? 'survival-smp', name: 'Iron Fortress', tag: 'IRON', description: 'Defenders of the realm and builders of grand monuments.', icon_url: null, banner_url: null, leader_uuid: 'mock-user-1', member_count: 5, max_members: 20, claims_count: 8, created_at: '2026-09-01T00:00:00Z' };
case 'get_guild_wallet':
return mockWallet();
case 'transfer_guild_wallet': {
const amt = Number(args?.amount);
if (args?.withdraw) { if (amt > wallet.balance) throw 'Insufficient funds'; wallet.balance -= amt; wallet.my_balance += amt; }
else { if (amt > wallet.my_balance) throw 'Insufficient funds'; wallet.balance += amt; wallet.my_balance -= amt; }
wallet.transactions.unshift({ id: Date.now(), actor_uuid: 'mock-user-1', kind: args?.withdraw ? 'withdraw' : 'deposit', amount: amt, created_at: new Date().toISOString() });
return { balance: wallet.balance };
}
case 'get_guild_claims':
return [
{ id: 101, guild_id: 'g1', instance_id: args?.instanceId ?? 'survival-smp', dimension: args?.dimension ?? 'minecraft:overworld', chunk_x: 0, chunk_z: 0, claimed_by_uuid: 'mock-user-1', claimed_at: '2026-09-10T00:00:00Z', guild_name: 'Iron Fortress', guild_tag: 'IRON' },
+4 -53
View File
@@ -2,10 +2,11 @@
import { onMount } from 'svelte';
import {
Shield, Map, Users, MessageSquare, Plus, Crown, UserCheck, Trash2,
Pin, LoaderCircle, RefreshCw, ChevronDown, Check, AlertCircle, Sparkles, Send
Pin, LoaderCircle, RefreshCw, Landmark, Sparkles, Send
} from '@lucide/svelte';
import Avatar from '../components/Avatar.svelte';
import ChunkMap from '../components/ChunkMap.svelte';
import GuildWallet from '../components/GuildWallet.svelte';
import Modal from '../components/Modal.svelte';
import { activeAccount, app, instances, selectedInstance, toast } from '../lib/store.svelte';
import { invoke } from '../lib/tauri';
@@ -19,31 +20,6 @@
let activeTab = $state<'territory' | 'members' | 'feed' | 'wallet'>('territory');
let loading = $state(false);
let walletServers = $state<Array<{ id: number; name: string; instance_id: string }>>([]);
let walletServerId = $state<number | null>(null);
let wallet = $state<{ balance: number; transactions: Array<{ id: number; actor_uuid: string; kind: string; amount: number; created_at: string }> } | null>(null);
let walletAmount = $state('');
let walletBusy = $state(false);
const canWithdraw = $derived(members.some(m => m.uuid === activeAccount()?.uuid && ['leader', 'officer'].includes(m.role)));
async function loadWallet() {
if (!myGuild || !walletServerId) { wallet = null; return; }
try { wallet = await invoke('get_guild_wallet', { guildId: myGuild.id, serverId: walletServerId }); }
catch (e: any) { toast(e?.message ?? 'Unable to load guild wallet', 'error'); }
}
async function transferWallet(withdraw: boolean) {
if (!myGuild || !walletServerId || walletBusy) return;
const amount = Number(walletAmount);
if (!Number.isFinite(amount) || amount <= 0 || Math.round(amount * 100) !== amount * 100) { toast('Enter a valid amount with at most two decimals', 'error'); return; }
walletBusy = true;
try {
await invoke('transfer_guild_wallet', { guildId: myGuild.id, serverId: walletServerId, amount, withdraw });
walletAmount = '';
await loadWallet();
toast(withdraw ? 'Funds withdrawn' : 'Funds deposited', 'ok');
} catch (e: any) { toast(e?.message ?? 'Transfer failed', 'error'); }
finally { walletBusy = false; }
}
// Create Guild Modal
let createModalOpen = $state(false);
@@ -97,11 +73,6 @@
try { walletServers = (await invoke<{ servers: typeof walletServers }>('get_public_servers')).servers; }
catch { walletServers = []; }
});
$effect(() => {
const available = walletServers.filter(s => s.instance_id === selectedInstId);
if (!available.some(s => s.id === walletServerId)) walletServerId = available[0]?.id ?? null;
});
$effect(() => { if (activeTab === 'wallet' && myGuild && walletServerId) loadWallet(); });
async function handleCreateGuild() {
if (!newGuildName.trim() || !newGuildTag.trim()) {
@@ -238,7 +209,7 @@
<MessageSquare size={15} /> Guild Feed ({posts.length})
</button>
<button class:active={activeTab === 'wallet'} onclick={() => (activeTab = 'wallet')}>
Guild Wallet
<Landmark size={15} /> Treasury
</button>
</div>
</div>
@@ -265,27 +236,7 @@
/>
</div>
{:else if activeTab === 'wallet'}
<div class="wallet-tab glass">
<h3>Guild Wallet</h3>
<label>Game server
<select bind:value={walletServerId}>
{#each walletServers.filter(s => s.instance_id === selectedInstId) as server}<option value={server.id}>{server.name}</option>{/each}
</select>
</label>
{#if !walletServerId}<p>Link a game server to this instance to use the guild wallet.</p>{/if}
{#if wallet}
<p>Balance: ${wallet.balance.toFixed(2)}</p>
<div class="wallet-actions">
<input type="number" min="0.01" step="0.01" placeholder="Amount" bind:value={walletAmount} aria-label="Transfer amount" />
<button class="primary sm" disabled={walletBusy || !walletServerId} onclick={() => transferWallet(false)}>Deposit</button>
{#if canWithdraw}<button class="ghost sm" disabled={walletBusy || !walletServerId} onclick={() => transferWallet(true)}>Withdraw</button>{/if}
</div>
<h4>Recent transfers</h4>
{#each wallet.transactions as tx (tx.id)}
<p>{tx.kind === 'withdraw' ? '−' : '+'}${tx.amount.toFixed(2)} · {new Date(tx.created_at).toLocaleString()}</p>
{:else}<p>No transfers yet.</p>{/each}
{/if}
</div>
<GuildWallet guild={myGuild} {members} servers={walletServers} instanceId={selectedInstId} />
{:else if activeTab === 'members'}
<div class="members-tab">
<div class="members-grid">
+21 -2
View File
@@ -1,17 +1,32 @@
<script lang="ts">
import { SlidersHorizontal, SquareTerminal, Package, MessageCircle, Globe, ShoppingBag, CirclePlay, Tv, Code, AtSign, Link, Trophy } from '@lucide/svelte';
import { SlidersHorizontal, SquareTerminal, Package, MessageCircle, Globe, ShoppingBag, CirclePlay, Tv, Code, AtSign, Link, Trophy, Map as MapIcon } from '@lucide/svelte';
import PlayButton from '../components/PlayButton.svelte';
import ServerStatus from '../components/ServerStatus.svelte';
import NewsFeed from '../components/NewsFeed.svelte';
import Console from '../components/Console.svelte';
import LiveMapModal from '../components/LiveMapModal.svelte';
import { abs, activeAccount, app, instances, selectedInstance } from '../lib/store.svelte';
import { loaderName, shortLoaderVersion } from '../lib/format';
import { openUrl } from '../lib/tauri';
import { invoke, openUrl } from '../lib/tauri';
const inst = $derived(selectedInstance());
const b = $derived(app.manifest!.branding);
// 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 };
// 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 mapOpen = $state(false);
$effect(() => {
const id = inst?.id;
mapServer = null;
mapOpen = false;
if (!id) return;
let alive = true;
invoke<{ servers: Array<{ id: number; name: string; instance_id: string; live_map?: boolean }> }>('get_public_servers')
.then((r) => { if (alive) mapServer = r.servers.find((s) => s.instance_id === id && s.live_map) ?? null; })
.catch(() => {});
return () => { alive = false; };
});
const hour = new Date().getHours();
const greet = hour < 5 ? 'Late night session' : hour < 12 ? 'Good morning' : hour < 18 ? 'Good afternoon' : 'Good evening';
</script>
@@ -42,6 +57,7 @@
<button class="glass tool" onclick={() => (app.instanceSettings = inst.id)} title="Instance settings"><SlidersHorizontal size={17} /></button>
<button class="glass tool" class:on={app.consoleOpen} onclick={() => (app.consoleOpen = !app.consoleOpen)} title="Game console"><SquareTerminal size={17} /></button>
<button class="glass tool" onclick={() => (app.view = 'stats')} title="Player stats & leaderboards"><Trophy size={17} /></button>
{#if mapServer}<button class="glass tool live" onclick={() => (mapOpen = true)} title="Open the live 3D map"><MapIcon size={17} /> <span>Live Map</span></button>{/if}
{#if inst.server && b.features.server_status}<ServerStatus server={inst.server} />{/if}
</div>
</section>
@@ -66,6 +82,7 @@
</aside>
{#if app.consoleOpen}<Console />{/if}
{#if mapOpen && mapServer}<LiveMapModal serverId={mapServer.id} serverName={mapServer.name} onclose={() => (mapOpen = false)} />{/if}
</div>
<style>
@@ -81,6 +98,8 @@
.actions { grid-column: 1; grid-row: 2; display: flex; flex-direction: column; gap: 0.8rem; animation: fade 0.3s ease both; }
.tools { flex-wrap: wrap; gap: 0.6rem; }
.tool { width: 2.75rem; height: 2.75rem; padding: 0; border-radius: var(--radius); color: color-mix(in srgb, var(--text) 75%, transparent); background: var(--surface); }
.tool.live { width: auto; padding: 0 1rem; display: inline-flex; align-items: center; gap: 0.5rem; font-size: 0.85rem; font-weight: 560; color: var(--text); border-color: color-mix(in srgb, var(--accent) 45%, transparent); background: color-mix(in srgb, var(--accent) 14%, var(--surface)); }
.tool.live:hover { background: color-mix(in srgb, var(--accent) 24%, var(--surface)); }
.tool.on { color: var(--text); border-color: var(--line-strong); background: color-mix(in srgb, var(--text) 8%, var(--surface)); }
.side { grid-column: 2; grid-row: 1 / 3; display: flex; flex-direction: column; justify-content: space-between; gap: 1rem; min-height: 0; animation: fade 0.3s ease both; }
.links { display: flex; flex-wrap: wrap; gap: 0.45rem; }