241 lines
9.5 KiB
Svelte
241 lines
9.5 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte';
|
|
import { Shield, Users, MapPin, Trash2, Search, ExternalLink, Flag } from '@lucide/svelte';
|
|
import Modal from '../components/Modal.svelte';
|
|
import { get, del } from '../lib/api';
|
|
import { toast, toastError } from '../lib/toast.svelte';
|
|
import type { Guild, Instance } from '../lib/types';
|
|
|
|
let guilds = $state<Guild[]>([]);
|
|
let instances = $state<Instance[]>([]);
|
|
let loading = $state(true);
|
|
let search = $state('');
|
|
let selectedInstance = $state<string>('all');
|
|
let deleteConfirm = $state<Guild | null>(null);
|
|
let deleteOpen = $state(false);
|
|
|
|
async function loadData() {
|
|
loading = true;
|
|
try {
|
|
const [g, inst] = await Promise.all([
|
|
get<any[]>('/api/admin/guilds').catch(() => []),
|
|
get<Instance[]>('/api/admin/instances').catch(() => []),
|
|
]);
|
|
guilds = (g || []).map((x: any) => ({
|
|
id: x.id || '',
|
|
instance_id: x.instance_id || '',
|
|
name: x.name || 'Unnamed Guild',
|
|
tag: x.tag || '',
|
|
description: x.description || '',
|
|
motd: x.motd || '',
|
|
leader_uuid: x.leader_uuid || '',
|
|
icon_url: x.icon_url || null,
|
|
banner_url: x.banner_url || null,
|
|
level: x.level || 1,
|
|
xp: x.xp || 0,
|
|
max_claims: x.max_claims || 10,
|
|
member_count: x.member_count ?? 1,
|
|
max_members: x.max_members || 50,
|
|
claims_count: x.claims_count ?? 0,
|
|
created_at: x.created_at || new Date().toISOString(),
|
|
}));
|
|
instances = inst || [];
|
|
} catch (e) {
|
|
toastError(e);
|
|
} finally {
|
|
loading = false;
|
|
}
|
|
}
|
|
|
|
onMount(() => {
|
|
loadData();
|
|
});
|
|
|
|
let filtered = $derived(
|
|
guilds.filter((g) => {
|
|
if (selectedInstance !== 'all' && g.instance_id !== selectedInstance) return false;
|
|
if (search.trim()) {
|
|
const s = search.toLowerCase();
|
|
return g.name.toLowerCase().includes(s) || g.tag.toLowerCase().includes(s) || g.id.toLowerCase().includes(s);
|
|
}
|
|
return true;
|
|
})
|
|
);
|
|
|
|
async function remove(g: Guild) {
|
|
try {
|
|
await del(`/api/admin/guilds/${g.id}`);
|
|
toast(`Guild [${g.tag}] ${g.name} deleted`);
|
|
deleteConfirm = null;
|
|
await loadData();
|
|
} catch (e) {
|
|
toastError(e);
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<div class="page">
|
|
<header>
|
|
<div>
|
|
<h1>Guilds & Land Claims Management</h1>
|
|
<p>View instances' guilds, member counts, active chunk claims, and moderate guilds.</p>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="stats-row">
|
|
<div class="stat-card">
|
|
<span class="label">Total Guilds</span>
|
|
<span class="value">{guilds.length}</span>
|
|
</div>
|
|
<div class="stat-card">
|
|
<span class="label">Total Members</span>
|
|
<span class="value text-accent">{guilds.reduce((acc, g) => acc + g.member_count, 0)}</span>
|
|
</div>
|
|
<div class="stat-card">
|
|
<span class="label">Claimed Chunks</span>
|
|
<span class="value text-success">{guilds.reduce((acc, g) => acc + g.claims_count, 0)}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="toolbar">
|
|
<div class="search-box">
|
|
<Search size={16} />
|
|
<input type="text" placeholder="Search guilds by name, tag, or id..." bind:value={search} />
|
|
</div>
|
|
|
|
<div class="filters">
|
|
<select bind:value={selectedInstance}>
|
|
<option value="all">All Instances</option>
|
|
{#each instances as inst}
|
|
<option value={inst.id}>{inst.name}</option>
|
|
{/each}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{#if loading}
|
|
<div class="empty">Loading guilds...</div>
|
|
{:else if filtered.length === 0}
|
|
<div class="empty">No guilds found.</div>
|
|
{:else}
|
|
<div class="table-wrap">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Guild</th>
|
|
<th>Tag</th>
|
|
<th>Instance</th>
|
|
<th>Leader</th>
|
|
<th>Members</th>
|
|
<th>Claimed Chunks</th>
|
|
<th>Created</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{#each filtered as g}
|
|
<tr>
|
|
<td>
|
|
<div class="guild-cell">
|
|
{#if g.icon_url}
|
|
<img src={g.icon_url} alt="" class="guild-avatar" />
|
|
{:else}
|
|
<div class="guild-avatar fallback">
|
|
<Shield size={18} />
|
|
</div>
|
|
{/if}
|
|
<div>
|
|
<strong>{g.name}</strong>
|
|
{#if g.description}
|
|
<p class="guild-desc">{g.description}</p>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td>
|
|
<span class="tag-badge">[{g.tag}]</span>
|
|
</td>
|
|
<td>
|
|
<code class="instance-code">{g.instance_id}</code>
|
|
</td>
|
|
<td>
|
|
<code class="uuid-code">{g.leader_uuid.slice(0, 8)}...</code>
|
|
</td>
|
|
<td>
|
|
<span class="count-pill">
|
|
<Users size={13} /> {g.member_count} / {g.max_members}
|
|
</span>
|
|
</td>
|
|
<td>
|
|
<span class="claims-pill">
|
|
<MapPin size={13} /> {g.claims_count} chunks
|
|
</span>
|
|
</td>
|
|
<td>
|
|
<span class="date-text">{new Date(g.created_at).toLocaleDateString()}</span>
|
|
</td>
|
|
<td>
|
|
<button class="ghost icon danger" title="Disband guild" onclick={() => { deleteConfirm = g; deleteOpen = true; }}>
|
|
<Trash2 size={15} />
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
{/each}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if deleteConfirm}
|
|
<Modal bind:open={deleteOpen} title="Disband Guild">
|
|
<p>Are you sure you want to forcibly disband <strong>[{deleteConfirm.tag}] {deleteConfirm.name}</strong>?</p>
|
|
<p class="warn-note">This will kick all {deleteConfirm.member_count} members and unclaim all {deleteConfirm.claims_count} chunks of land!</p>
|
|
<div class="modal-actions">
|
|
<button class="ghost" onclick={() => { deleteOpen = false; deleteConfirm = null; }}>Cancel</button>
|
|
<button class="danger" onclick={() => { if (deleteConfirm) { remove(deleteConfirm); deleteOpen = false; } }}>Disband Guild</button>
|
|
</div>
|
|
</Modal>
|
|
{/if}
|
|
|
|
<style>
|
|
.page { padding: 32px; max-width: 1400px; margin: 0 auto; display: flex; flex-direction: column; gap: 24px; }
|
|
header h1 { font-size: 1.75rem; font-weight: 700; margin: 0 0 6px; }
|
|
header p { color: var(--muted); margin: 0; font-size: 0.95rem; }
|
|
|
|
.stats-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; }
|
|
.stat-card { background: var(--bg-2); border: 1px solid var(--line); border-radius: 12px; padding: 18px 20px; display: flex; flex-direction: column; gap: 4px; }
|
|
.stat-card .label { font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); }
|
|
.stat-card .value { font-size: 1.8rem; font-weight: 700; }
|
|
.text-success { color: var(--success, #22c55e); }
|
|
.text-accent { color: var(--accent, #6366f1); }
|
|
|
|
.toolbar { display: flex; justify-content: space-between; align-items: center; gap: 16px; flex-wrap: wrap; }
|
|
.search-box { display: flex; align-items: center; gap: 10px; background: var(--bg-2); border: 1px solid var(--line); border-radius: 10px; padding: 8px 14px; flex: 1; min-width: 260px; max-width: 500px; }
|
|
.search-box input { background: transparent; border: none; outline: none; color: var(--text); width: 100%; font-size: 0.9rem; }
|
|
select { background: var(--bg-2); border: 1px solid var(--line); border-radius: 10px; padding: 8px 14px; color: var(--text); font-size: 0.85rem; outline: none; }
|
|
|
|
.table-wrap { background: var(--bg-2); border: 1px solid var(--line); border-radius: 14px; overflow: hidden; }
|
|
table { width: 100%; border-collapse: collapse; text-align: left; font-size: 0.9rem; }
|
|
th { padding: 12px 16px; background: rgba(255,255,255,0.02); border-bottom: 1px solid var(--line); color: var(--muted); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; }
|
|
td { padding: 14px 16px; border-bottom: 1px solid rgba(255,255,255,0.04); vertical-align: middle; }
|
|
tr:last-child td { border-bottom: none; }
|
|
tr:hover td { background: rgba(255,255,255,0.015); }
|
|
|
|
.guild-cell { display: flex; align-items: center; gap: 12px; }
|
|
.guild-avatar { width: 36px; height: 36px; border-radius: 8px; object-fit: cover; }
|
|
.guild-avatar.fallback { background: rgba(255,255,255,0.06); display: flex; align-items: center; justify-content: center; color: var(--muted); }
|
|
.guild-desc { margin: 2px 0 0; font-size: 0.78rem; color: var(--muted); }
|
|
|
|
.tag-badge { font-family: monospace; font-weight: 700; color: #a5b4fc; background: rgba(99,102,241,0.12); padding: 2px 8px; border-radius: 4px; border: 1px solid rgba(99,102,241,0.25); }
|
|
.instance-code, .uuid-code { font-size: 0.8rem; color: var(--muted); background: rgba(255,255,255,0.04); padding: 2px 6px; border-radius: 4px; }
|
|
|
|
.count-pill { display: inline-flex; align-items: center; gap: 6px; font-size: 0.85rem; color: var(--text-2); }
|
|
.claims-pill { display: inline-flex; align-items: center; gap: 6px; font-size: 0.85rem; color: #4ade80; }
|
|
.date-text { font-size: 0.8rem; color: var(--muted); }
|
|
|
|
.warn-note { color: #f87171; font-size: 0.88rem; }
|
|
.modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 16px; }
|
|
.empty { text-align: center; padding: 60px 20px; color: var(--muted); background: var(--bg-2); border-radius: 12px; border: 1px solid var(--line); }
|
|
</style>
|