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

+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; }