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:
12 files changed
+707
-166
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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; }
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in new issue
Block a user